diff options
73 files changed, 1636 insertions, 309 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 5248609b586..eeff84d47ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,18 +2,6 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. -## 13.3.5 (2020-09-04) - -### Fixed (6 changes) - -- Coerce string object storage options to booleans. !39901 -- Fix Jira importer user mapping limit. !40310 -- Fix auto-deploy-image external chart dependencies. !40730 -- Fix ActiveRecord::IrreversibleOrderError during restore from backup. !40789 -- Fix wrong caching logic in ProcessRefChangesService. !40821 -- Update the 2FA user update check to account for rounding errors. !41327 - - ## 13.3.4 (2020-09-02) ### Security (1 change) @@ -601,14 +589,6 @@ entry. - Replace fa-pencil icon with GitLab SVG. !39648 -## 13.2.9 (2020-09-04) - -### Fixed (2 changes) - -- Fix ActiveRecord::IrreversibleOrderError during restore from backup. !40789 -- Update the 2FA user update check to account for rounding errors. !41327 - - ## 13.2.8 (2020-09-02) ### Security (1 change) diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 92517203972..a75646db162 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -237,7 +237,7 @@ export default class Clusters { } addBannerCloseHandler(el, status) { - el.querySelector('.js-close-banner').addEventListener('click', () => { + el.querySelector('.js-close').addEventListener('click', () => { el.classList.add('hidden'); this.setBannerDismissedState(status, true); }); 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 a981c67e7be..e7d7b7d9f1b 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,6 @@ +import { isEmpty } from 'lodash'; +import { queryToObject } from '~/lib/utils/url_utility'; + /** * Strips enclosing quotations from a string if it has one. * @@ -29,3 +32,133 @@ export const uniqueTokens = tokens => { return uniques; }, []); }; + +/** + * Creates a token from a type and a filter. Example returned object + * { type: 'myType', value: { data: 'myData', operator: '= '} } + * @param {String} type the name of the filter + * @param {Object} + * @param {Object.value} filter value to be returned as token data + * @param {Object.operator} filter operator to be retuned as token operator + * @return {Object} + * @return {Object.type} token type + * @return {Object.value} token value + */ +function createToken(type, filter) { + return { type, value: { data: filter.value, operator: filter.operator } }; +} + +/** + * This function takes a filter object and translates it into a token array + * @param {Object} filters + * @param {Object.myFilterName} a single filter value or an array of filters + * @return {Array} tokens an array of tokens created from filter values + */ +export function prepareTokens(filters = {}) { + return Object.keys(filters).reduce((memo, key) => { + const value = filters[key]; + if (!value) { + return memo; + } + if (Array.isArray(value)) { + return [...memo, ...value.map(filterValue => createToken(key, filterValue))]; + } + + return [...memo, createToken(key, value)]; + }, []); +} + +export function processFilters(filters) { + return filters.reduce((acc, token) => { + const { type, value } = token; + const { operator } = value; + const tokenValue = value.data; + + if (!acc[type]) { + acc[type] = []; + } + + acc[type].push({ value: tokenValue, operator }); + return acc; + }, {}); +} + +/** + * This function takes a filter object and maps it into a query object. Example filter: + * { myFilterName: { value: 'foo', operator: '=' } } + * gets translated into: + * { myFilterName: 'foo', 'not[myFilterName]': null } + * @param {Object} filters + * @param {Object.myFilterName} a single filter value or an array of filters + * @return {Object} query object with both filter name and not-name with values + */ +export function filterToQueryObject(filters = {}) { + return Object.keys(filters).reduce((memo, key) => { + const filter = filters[key]; + + let selected; + let unselected; + if (Array.isArray(filter)) { + selected = filter.filter(item => item.operator === '=').map(item => item.value); + unselected = filter.filter(item => item.operator === '!=').map(item => item.value); + } else { + selected = filter?.operator === '=' ? filter.value : null; + unselected = filter?.operator === '!=' ? filter.value : null; + } + + if (isEmpty(selected)) { + selected = null; + } + if (isEmpty(unselected)) { + unselected = null; + } + + return { ...memo, [key]: selected, [`not[${key}]`]: unselected }; + }, {}); +} + +/** + * Extracts filter name from url name, e.g. `not[my_filter]` => `my_filter` + * and returns the operator with it depending on the filter name + * @param {String} filterName from url + * @return {Object} + * @return {Object.filterName} extracted filtern ame + * @return {Object.operator} `=` or `!=` + */ +function extractNameAndOperator(filterName) { + // eslint-disable-next-line @gitlab/require-i18n-strings + if (filterName.startsWith('not[') && filterName.endsWith(']')) { + return { filterName: filterName.slice(4, -1), operator: '!=' }; + } + + return { filterName, operator: '=' }; +} + +/** + * This function takes a URL query string and maps it into a filter object. Example query string: + * '?myFilterName=foo' + * gets translated into: + * { myFilterName: { value: 'foo', operator: '=' } } + * @param {String} query URL quert string, e.g. from `window.location.search` + * @return {Object} filter object with filter names and their values + */ +export function urlQueryToFilter(query = '') { + const filters = queryToObject(query, { gatherArrays: true }); + return Object.keys(filters).reduce((memo, key) => { + const value = filters[key]; + if (!value) { + return memo; + } + const { filterName, operator } = extractNameAndOperator(key); + let previousValues = []; + if (Array.isArray(memo[filterName])) { + previousValues = memo[filterName]; + } + if (Array.isArray(value)) { + const newAdditions = value.filter(Boolean).map(item => ({ value: item, operator })); + return { ...memo, [filterName]: [...previousValues, ...newAdditions] }; + } + + return { ...memo, [filterName]: { value, operator } }; + }, {}); +} diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js index 74c7a3853bf..a214151bae9 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js @@ -29,6 +29,7 @@ const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) => const emphasisNode = 'EM, I'; const strongNode = 'STRONG, B'; const headingNode = 'H1, H2, H3, H4, H5, H6'; + const preCodeNode = 'PRE CODE'; return { TEXT_NODE(node) { @@ -91,6 +92,13 @@ const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) => return attributeDefinition ? `${result.trimRight()}\n${attributeDefinition}\n\n` : result; }, + [preCodeNode](node, subContent) { + const isReferenceDefinition = Boolean(node.dataset.sseReferenceDefinition); + + return isReferenceDefinition + ? `\n\n${node.innerText}\n` + : baseRenderer.convert(node, subContent); + }, }; }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js index 4ec45ecd3a7..3f9c6291d1b 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js @@ -1,5 +1,3 @@ -import { renderUneditableBranch as render } from './render_utils'; - const identifierRegex = /(^\[.+\]: .+)/; const isIdentifier = text => { @@ -10,4 +8,33 @@ const canRender = (node, context) => { return isIdentifier(context.getChildrenText(node)); }; +const getReferenceDefinitions = (node, definitions = '') => { + if (!node) { + return definitions; + } + + const definition = node.type === 'text' ? node.literal : '\n'; + + return getReferenceDefinitions(node.next, `${definitions}${definition}`); +}; + +const render = (node, { skipChildren }) => { + const content = getReferenceDefinitions(node.firstChild); + + skipChildren(); + + return [ + { + type: 'openTag', + tagName: 'pre', + classNames: ['code-block', 'language-markdown'], + attributes: { 'data-sse-reference-definition': true }, + }, + { type: 'openTag', tagName: 'code' }, + { type: 'text', content }, + { type: 'closeTag', tagName: 'code' }, + { type: 'closeTag', tagName: 'pre' }, + ]; +}; + export default { canRender, render }; diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index c3ea7f28530..050f83edacb 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -149,7 +149,7 @@ class Admin::UsersController < Admin::ApplicationController password_confirmation: params[:user][:password_confirmation] } - password_params[:password_expires_at] = Time.current unless changing_own_password? + password_params[:password_expires_at] = Time.current if admin_making_changes_for_another_user? user_params_with_pass.merge!(password_params) end @@ -157,6 +157,7 @@ class Admin::UsersController < Admin::ApplicationController respond_to do |format| result = Users::UpdateService.new(current_user, user_params_with_pass.merge(user: user)).execute do |user| user.skip_reconfirmation! + user.send_only_admin_changed_your_password_notification! if admin_making_changes_for_another_user? end if result[:status] == :success @@ -197,8 +198,8 @@ class Admin::UsersController < Admin::ApplicationController protected - def changing_own_password? - user == current_user + def admin_making_changes_for_another_user? + user != current_user end def user diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb index 5a55e0e9e4e..bc51830c119 100644 --- a/app/controllers/profiles/notifications_controller.rb +++ b/app/controllers/profiles/notifications_controller.rb @@ -1,13 +1,11 @@ # frozen_string_literal: true class Profiles::NotificationsController < Profiles::ApplicationController - NOTIFICATIONS_PER_PAGE = 10 - # rubocop: disable CodeReuse/ActiveRecord def show @user = current_user @user_groups = user_groups - @group_notifications = user_groups.map { |group| current_user.notification_settings_for(group, inherit: true) } + @group_notifications = UserGroupNotificationSettingsFinder.new(current_user, user_groups).execute @project_notifications = current_user.notification_settings.for_projects.order(:id) .preload_source_route @@ -35,6 +33,6 @@ class Profiles::NotificationsController < Profiles::ApplicationController private def user_groups - GroupsFinder.new(current_user, all_available: false).execute.order_name_asc.page(params[:page]).per(NOTIFICATIONS_PER_PAGE) + GroupsFinder.new(current_user, all_available: false).execute.order_name_asc.page(params[:page]) end end diff --git a/app/finders/user_group_notification_settings_finder.rb b/app/finders/user_group_notification_settings_finder.rb new file mode 100644 index 00000000000..a29cf409692 --- /dev/null +++ b/app/finders/user_group_notification_settings_finder.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class UserGroupNotificationSettingsFinder + def initialize(user, groups) + @user = user + @groups = groups + end + + def execute + groups_with_ancestors = Gitlab::ObjectHierarchy.new(groups).base_and_ancestors + + @loaded_groups_with_ancestors = groups_with_ancestors.index_by(&:id) + @loaded_notification_settings = user.notification_settings_for_groups(groups_with_ancestors).preload_source_route.index_by(&:source_id) + + groups.map do |group| + find_notification_setting_for(group) + end + end + + private + + attr_reader :user, :groups, :loaded_groups_with_ancestors, :loaded_notification_settings + + def find_notification_setting_for(group) + return loaded_notification_settings[group.id] if loaded_notification_settings[group.id] + return user.notification_settings.build(source: group) if group.parent_id.nil? + + parent_setting = loaded_notification_settings[group.parent_id] + + if should_copy?(parent_setting) + user.notification_settings.build(source: group) do |ns| + ns.assign_attributes(parent_setting.slice(*NotificationSetting.allowed_fields)) + end + else + find_notification_setting_for(loaded_groups_with_ancestors[group.parent_id]) + end + end + + def should_copy?(parent_setting) + return false unless parent_setting + + parent_setting.level != NotificationSetting.levels[:global] || parent_setting.notification_email.present? + end +end diff --git a/app/graphql/resolvers/concerns/looks_ahead.rb b/app/graphql/resolvers/concerns/looks_ahead.rb index becc6debd33..e7230287e13 100644 --- a/app/graphql/resolvers/concerns/looks_ahead.rb +++ b/app/graphql/resolvers/concerns/looks_ahead.rb @@ -46,7 +46,7 @@ module LooksAhead if lookahead.selects?(:nodes) lookahead.selection(:nodes) elsif lookahead.selects?(:edges) - lookahead.selection(:edges).selection(:nodes) + lookahead.selection(:edges).selection(:node) end end end diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index 923d22e1be5..54542cdace6 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -34,7 +34,8 @@ module Resolvers def preloads { - alert_management_alert: [:alert_management_alert] + alert_management_alert: [:alert_management_alert], + labels: [:labels] } end diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index 6df21056367..e49cb3b3932 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -42,8 +42,7 @@ module Types field :assignees, Types::UserType.connection_type, null: true, complexity: 5, description: 'Assignees of the issue' - # Remove complexity when BatchLoader is used - field :labels, Types::LabelType.connection_type, null: true, complexity: 5, + field :labels, Types::LabelType.connection_type, null: true, description: 'Labels of the issue' field :milestone, Types::MilestoneType, null: true, description: 'Milestone of the issue', diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index 9a44b66002a..d5c22927991 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -181,6 +181,10 @@ module EmailsHelper _('Hi %{username}!') % { username: sanitize_name(user.name) } end + def say_hello(user) + _('Hello, %{username}!') % { username: sanitize_name(user.name) } + end + def two_factor_authentication_disabled_text _('Two-factor authentication has been disabled for your GitLab account.') end @@ -190,7 +194,7 @@ module EmailsHelper case format when :html - settings_link_to = link_to(_('two-factor authentication settings'), url, target: :_blank, rel: 'noopener noreferrer').html_safe + settings_link_to = generate_link(_('two-factor authentication settings'), url).html_safe _("If you want to re-enable two-factor authentication, visit the %{settings_link_to} page.").html_safe % { settings_link_to: settings_link_to } else _('If you want to re-enable two-factor authentication, visit %{two_factor_link}') % @@ -198,8 +202,28 @@ module EmailsHelper end end + def admin_changed_password_text(format: nil) + url = Gitlab.config.gitlab.url + + case format + when :html + link_to = generate_link(url, url).html_safe + _('An administrator changed the password for your GitLab account on %{link_to}.').html_safe % { link_to: link_to } + else + _('An administrator changed the password for your GitLab account on %{link_to}.') % { link_to: url } + end + end + + def contact_your_administrator_text + _('Please contact your administrator with any questions.') + end + private + def generate_link(text, url) + link_to(text, url, target: :_blank, rel: 'noopener noreferrer') + end + def show_footer? email_header_and_footer_enabled? && current_appearance&.show_footer? end diff --git a/app/mailers/devise_mailer.rb b/app/mailers/devise_mailer.rb index cbaf53fced1..a02670aed90 100644 --- a/app/mailers/devise_mailer.rb +++ b/app/mailers/devise_mailer.rb @@ -9,6 +9,10 @@ class DeviseMailer < Devise::Mailer helper EmailsHelper helper ApplicationHelper + def password_change_by_admin(record, opts = {}) + devise_mail(record, :password_change_by_admin, opts) + end + protected def subject_for(key) diff --git a/app/models/concerns/admin_changed_password_notifier.rb b/app/models/concerns/admin_changed_password_notifier.rb new file mode 100644 index 00000000000..f6c2abc7e0f --- /dev/null +++ b/app/models/concerns/admin_changed_password_notifier.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module AdminChangedPasswordNotifier + # This module is responsible for triggering the `Password changed by administrator` emails + # when a GitLab administrator changes the password of another user. + + # Usage + # These emails are disabled by default and are never trigerred after updating the password, unless + # explicitly specified. + + # To explicitly trigger this email, the `send_only_admin_changed_your_password_notification!` + # method should be called, so like: + + # user = User.find_by(email: 'hello@example.com') + # user.send_only_admin_changed_your_password_notification! + # user.password = user.password_confirmation = 'new_password' + # user.save! + + # The `send_only_admin_changed_your_password_notification` has 2 responsibilities. + # It prevents triggering Devise's default `Password changed` email. + # It trigggers the `Password changed by administrator` email. + + # It is important to skip sending the default Devise email when sending out `Password changed by administrator` + # email because we should not be sending 2 emails for the same event, + # hence the only public API made available from this module is `send_only_admin_changed_your_password_notification!` + + # There is no public API made available to send the `Password changed by administrator` email, + # *without* skipping the default `Password changed` email, to prevent the problem mentioned above. + + extend ActiveSupport::Concern + + included do + after_update :send_admin_changed_your_password_notification, if: :send_admin_changed_your_password_notification? + end + + def initialize(*args, &block) + @allow_admin_changed_your_password_notification = false # These emails are off by default + super + end + + def send_only_admin_changed_your_password_notification! + skip_password_change_notification! # skip sending the default Devise 'password changed' notification + allow_admin_changed_your_password_notification! + end + + private + + def send_admin_changed_your_password_notification + send_devise_notification(:password_change_by_admin) + end + + def allow_admin_changed_your_password_notification! + @allow_admin_changed_your_password_notification = true # rubocop:disable Gitlab/ModuleWithInstanceVariables + end + + def send_admin_changed_your_password_notification? + self.class.send_password_change_notification && saved_change_to_encrypted_password? && + @allow_admin_changed_your_password_notification # rubocop:disable Gitlab/ModuleWithInstanceVariables + end +end diff --git a/app/models/issue.rb b/app/models/issue.rb index f4e1e5a6612..93fc3325b38 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -73,7 +73,8 @@ class Issue < ApplicationRecord enum issue_type: { issue: 0, - incident: 1 + incident: 1, + test_case: 2 ## EE-only } alias_attribute :parent_ids, :project_id diff --git a/app/models/user.rb b/app/models/user.rb index 7574f0865a0..061d958ea72 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -58,6 +58,8 @@ class User < ApplicationRecord devise :lockable, :recoverable, :rememberable, :trackable, :validatable, :omniauthable, :confirmable, :registerable + include AdminChangedPasswordNotifier + # This module adds async behaviour to Devise emails # and should be added after Devise modules are initialized. include AsyncDeviseEmail @@ -1461,6 +1463,11 @@ class User < ApplicationRecord end end + def notification_settings_for_groups(groups) + ids = groups.is_a?(ActiveRecord::Relation) ? groups.select(:id) : groups.map(&:id) + notification_settings.for_groups.where(source_id: ids) + end + # Lazy load global notification setting # Initializes User setting with Participating level if setting not persisted def global_notification_setting diff --git a/app/services/auto_merge/base_service.rb b/app/services/auto_merge/base_service.rb index 5c63dc34cb1..41236286d23 100644 --- a/app/services/auto_merge/base_service.rb +++ b/app/services/auto_merge/base_service.rb @@ -60,6 +60,21 @@ module AutoMerge end end + ## + # NOTE: This method is to be removed when `disallow_to_create_merge_request_pipelines_in_target_project` + # feature flag is removed. + def self.can_add_to_merge_train?(merge_request) + if Gitlab::Ci::Features.disallow_to_create_merge_request_pipelines_in_target_project?(merge_request.target_project) + merge_request.for_same_project? + else + true + end + end + + def can_add_to_merge_train?(merge_request) + self.class.can_add_to_merge_train?(merge_request) + end + private # Overridden in child classes diff --git a/app/services/merge_requests/create_pipeline_service.rb b/app/services/merge_requests/create_pipeline_service.rb index f9352f10fea..46c4c102091 100644 --- a/app/services/merge_requests/create_pipeline_service.rb +++ b/app/services/merge_requests/create_pipeline_service.rb @@ -48,12 +48,18 @@ module MergeRequests end def can_create_pipeline_in_target_project?(merge_request) - if Gitlab::Ci::Features.allow_to_create_merge_request_pipelines_in_target_project?(merge_request.target_project) - can?(current_user, :create_pipeline, merge_request.target_project) - else + if Gitlab::Ci::Features.disallow_to_create_merge_request_pipelines_in_target_project?(merge_request.target_project) merge_request.for_same_project? + else + can?(current_user, :create_pipeline, merge_request.target_project) && + can_update_source_branch_in_target_project?(merge_request) end end + + def can_update_source_branch_in_target_project?(merge_request) + ::Gitlab::UserAccess.new(current_user, container: merge_request.target_project) + .can_update_branch?(merge_request.source_branch_ref) + end end end diff --git a/app/views/clusters/clusters/_banner.html.haml b/app/views/clusters/clusters/_banner.html.haml index 3461831eda2..4a84745cf98 100644 --- a/app/views/clusters/clusters/_banner.html.haml +++ b/app/views/clusters/clusters/_banner.html.haml @@ -8,14 +8,14 @@ .hidden.row.js-cluster-api-unreachable.gl-alert.gl-alert-warning{ role: 'alert' } = sprite_icon('warning', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - %button.js-close-banner.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } + %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } = sprite_icon('close', css_class: 'gl-icon') .gl-alert-body = s_('ClusterIntegration|Your cluster API is unreachable. Please ensure your API URL is correct.') .hidden.js-cluster-authentication-failure.js-cluster-api-unreachable.gl-alert.gl-alert-warning{ role: 'alert' } = sprite_icon('warning', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - %button.js-close-banner.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } + %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } = sprite_icon('close', css_class: 'gl-icon') .gl-alert-body = s_('ClusterIntegration|There was a problem authenticating with your cluster. Please ensure your CA Certificate and Token are valid.') diff --git a/app/views/devise/mailer/password_change_by_admin.html.haml b/app/views/devise/mailer/password_change_by_admin.html.haml new file mode 100644 index 00000000000..d72f36838c5 --- /dev/null +++ b/app/views/devise/mailer/password_change_by_admin.html.haml @@ -0,0 +1,6 @@ += email_default_heading(say_hello(@resource)) + +%p + = admin_changed_password_text(format: :html) +%p + = contact_your_administrator_text diff --git a/app/views/devise/mailer/password_change_by_admin.text.erb b/app/views/devise/mailer/password_change_by_admin.text.erb new file mode 100644 index 00000000000..d0e7ab98641 --- /dev/null +++ b/app/views/devise/mailer/password_change_by_admin.text.erb @@ -0,0 +1,5 @@ +<%= say_hello(@resource) %> + +<%= admin_changed_password_text %> + +<%= contact_your_administrator_text %> diff --git a/changelogs/unreleased/229266-mlunoe-analytics-filter-module-list-values-follow-up.yml b/changelogs/unreleased/229266-mlunoe-analytics-filter-module-list-values-follow-up.yml new file mode 100644 index 00000000000..312bcc5fea3 --- /dev/null +++ b/changelogs/unreleased/229266-mlunoe-analytics-filter-module-list-values-follow-up.yml @@ -0,0 +1,6 @@ +--- +title: Fixed an issue where not all URL query parameters would apply to the filter + bar on initial load in the Value Stream Analytics page +merge_request: 40975 +author: +type: fixed diff --git a/changelogs/unreleased/235889-jira-importer-user-mapping-shows-50-users-max.yml b/changelogs/unreleased/235889-jira-importer-user-mapping-shows-50-users-max.yml new file mode 100644 index 00000000000..1fcf6c768e7 --- /dev/null +++ b/changelogs/unreleased/235889-jira-importer-user-mapping-shows-50-users-max.yml @@ -0,0 +1,5 @@ +--- +title: Fix Jira importer user mapping limit +merge_request: 40310 +author: +type: fixed diff --git a/changelogs/unreleased/243760-fix-link-reference-definitions.yml b/changelogs/unreleased/243760-fix-link-reference-definitions.yml new file mode 100644 index 00000000000..b20d2f10f07 --- /dev/null +++ b/changelogs/unreleased/243760-fix-link-reference-definitions.yml @@ -0,0 +1,5 @@ +--- +title: Render reference definitions as code blocks +merge_request: 41186 +author: +type: fixed diff --git a/changelogs/unreleased/27284-indicate-that-password-reset-was-initiated-by-an-admin-when-an-admi.yml b/changelogs/unreleased/27284-indicate-that-password-reset-was-initiated-by-an-admin-when-an-admi.yml new file mode 100644 index 00000000000..99bbf8c8427 --- /dev/null +++ b/changelogs/unreleased/27284-indicate-that-password-reset-was-initiated-by-an-admin-when-an-admi.yml @@ -0,0 +1,5 @@ +--- +title: Password changed emails must specify that password was changed by admin +merge_request: 40342 +author: +type: added diff --git a/changelogs/unreleased/bump-ado-image-to-v1-0-2.yml b/changelogs/unreleased/bump-ado-image-to-v1-0-2.yml new file mode 100644 index 00000000000..d24e9a1e6c3 --- /dev/null +++ b/changelogs/unreleased/bump-ado-image-to-v1-0-2.yml @@ -0,0 +1,5 @@ +--- +title: Fix auto-deploy-image external chart dependencies +merge_request: 40730 +author: +type: fixed diff --git a/changelogs/unreleased/cat-time-precision-2fa-ldap.yml b/changelogs/unreleased/cat-time-precision-2fa-ldap.yml new file mode 100644 index 00000000000..dc2cdaa8632 --- /dev/null +++ b/changelogs/unreleased/cat-time-precision-2fa-ldap.yml @@ -0,0 +1,5 @@ +--- +title: Update the 2FA user update check to account for rounding errors +merge_request: 41327 +author: +type: fixed diff --git a/changelogs/unreleased/fix-run-pipeline-in-target-project.yml b/changelogs/unreleased/fix-run-pipeline-in-target-project.yml new file mode 100644 index 00000000000..6d402b8050b --- /dev/null +++ b/changelogs/unreleased/fix-run-pipeline-in-target-project.yml @@ -0,0 +1,6 @@ +--- +title: Fix fork users cannot create pipelines in a fork project when parent project + protects all branches +merge_request: 40724 +author: +type: fixed diff --git a/changelogs/unreleased/fix_concurrent_backup.yml b/changelogs/unreleased/fix_concurrent_backup.yml new file mode 100644 index 00000000000..b71219d6919 --- /dev/null +++ b/changelogs/unreleased/fix_concurrent_backup.yml @@ -0,0 +1,5 @@ +--- +title: Fix deadlock in backup repositories rake task +merge_request: 41042 +author: +type: fixed diff --git a/changelogs/unreleased/id-remove-memoize-on-processing-ref-changes.yml b/changelogs/unreleased/id-remove-memoize-on-processing-ref-changes.yml new file mode 100644 index 00000000000..2c52d5f3f61 --- /dev/null +++ b/changelogs/unreleased/id-remove-memoize-on-processing-ref-changes.yml @@ -0,0 +1,5 @@ +--- +title: Fix wrong caching logic in ProcessRefChangesService +merge_request: 40821 +author: +type: fixed diff --git a/changelogs/unreleased/sh-fix-backup-restore-race.yml b/changelogs/unreleased/sh-fix-backup-restore-race.yml new file mode 100644 index 00000000000..ab5d4d8fcb0 --- /dev/null +++ b/changelogs/unreleased/sh-fix-backup-restore-race.yml @@ -0,0 +1,5 @@ +--- +title: Fix ActiveRecord::IrreversibleOrderError during restore from backup +merge_request: 40789 +author: +type: fixed diff --git a/config/feature_flags/development/ci_disallow_to_create_merge_request_pipelines_in_target_project.yml b/config/feature_flags/development/ci_disallow_to_create_merge_request_pipelines_in_target_project.yml new file mode 100644 index 00000000000..81a0d014b12 --- /dev/null +++ b/config/feature_flags/development/ci_disallow_to_create_merge_request_pipelines_in_target_project.yml @@ -0,0 +1,7 @@ +--- +name: ci_disallow_to_create_merge_request_pipelines_in_target_project +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40724 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/235119 +group: group::progressive delivery +type: development +default_enabled: false diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml index e8110e21766..e4a46be9bf3 100644 --- a/config/locales/devise.en.yml +++ b/config/locales/devise.en.yml @@ -25,6 +25,8 @@ en: subject: "Unlock instructions" password_change: subject: "Password Changed" + password_change_by_admin: + subject: "Password changed by administrator" omniauth_callbacks: failure: "Could not authenticate you from %{kind} because \"%{reason}\"." success: "Successfully authenticated from %{kind} account." diff --git a/doc/administration/index.md b/doc/administration/index.md index e60be2a2c3b..c46564506f5 100644 --- a/doc/administration/index.md +++ b/doc/administration/index.md @@ -231,6 +231,6 @@ who are aware of the risks. - [GitLab Developer Docs](../development/README.md) - [Repairing and recovering broken Git repositories](https://git.seveas.net/repairing-and-recovering-broken-git-repositories.html) - [Testing with OpenSSL](https://www.feistyduck.com/library/openssl-cookbook/online/ch-testing-with-openssl.html) - - [`Strace` zine](https://wizardzines.com/zines/strace/) + - [`strace` zine](https://wizardzines.com/zines/strace/) - GitLab.com-specific resources: - [Group SAML/SCIM setup](troubleshooting/group_saml_scim.md) diff --git a/doc/administration/operations/puma.md b/doc/administration/operations/puma.md index e7b4bb88faf..782b3cd6758 100644 --- a/doc/administration/operations/puma.md +++ b/doc/administration/operations/puma.md @@ -1,6 +1,6 @@ # Switching to Puma -As of GitLab 12.9, [Puma](https://github.com/puma/puma) has replaced [Unicorn](https://yhbt.net/unicorn/). +As of GitLab 12.9, [Puma](https://github.com/puma/puma) has replaced [Unicorn](https://yhbt.net/unicorn/) as the default web server. From GitLab 13.0, the following run Puma instead of Unicorn unless explicitly configured not to: diff --git a/doc/administration/raketasks/uploads/migrate.md b/doc/administration/raketasks/uploads/migrate.md index 8c020e91a15..8427575ea4b 100644 --- a/doc/administration/raketasks/uploads/migrate.md +++ b/doc/administration/raketasks/uploads/migrate.md @@ -1,6 +1,9 @@ # Uploads migrate Rake tasks **(CORE ONLY)** -`gitlab:uploads:migrate` migrates uploads between different storage types. +There is a Rake task for migrating uploads between different storage types. + +- Migrate all uploads with [`gitlab:uploads:migrate:all`](#all-in-one-rake-task) or +- To only migrate specific upload types, use [`gitlab:uploads:migrate`](#individual-rake-tasks). ## Migrate to object storage @@ -166,3 +169,17 @@ To migrate uploads from object storage to local storage: After running the Rake task, you can disable object storage by undoing the changes described in the instructions to [configure object storage](../../uploads.md#using-object-storage-core-only). + +## Troubleshooting + +### undefined method constantize + +You will see the following error if you run `gitlab:uploads:migrate` without parameters. + +```plaintext +rake aborted! +NoMethodError: undefined method `constantize' for nil:NilClass +``` + +- If you intend to migrate all uploads, use the all-in-one Rake task [`gitlab:uploads:migrate:all`](#all-in-one-rake-task). +- To migrate specific uploads, use [`gitlab:uploads:migrate`](#individual-rake-tasks) and supply the necessary parameters. diff --git a/doc/administration/uploads.md b/doc/administration/uploads.md index d9902208e93..71a41719003 100644 --- a/doc/administration/uploads.md +++ b/doc/administration/uploads.md @@ -1,4 +1,4 @@ -# Uploads administration +# Uploads administration **(CORE ONLY)** Uploads represent all user data that may be sent to GitLab as a single file. As an example, avatars and notes' attachments are uploads. Uploads are integral to GitLab functionality, and therefore cannot be disabled. @@ -108,7 +108,7 @@ _The uploads are stored by default in ``` 1. Save the file and [reconfigure GitLab](restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect. -1. Migrate any existing local uploads to the object storage using [`gitlab:uploads:migrate` Rake task](raketasks/uploads/migrate.md). +1. Migrate any existing local uploads to the object storage using [`gitlab:uploads:migrate:all` Rake task](raketasks/uploads/migrate.md). **In installations from source:** @@ -131,7 +131,7 @@ _The uploads are stored by default in ``` 1. Save the file and [restart GitLab](restart_gitlab.md#installations-from-source) for the changes to take effect. -1. Migrate any existing local uploads to the object storage using [`gitlab:uploads:migrate` Rake task](raketasks/uploads/migrate.md). +1. Migrate any existing local uploads to the object storage using [`gitlab:uploads:migrate:all` Rake task](raketasks/uploads/migrate.md). ### OpenStack example @@ -157,7 +157,7 @@ _The uploads are stored by default in ``` 1. Save the file and [reconfigure GitLab](restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect. -1. Migrate any existing local uploads to the object storage using [`gitlab:uploads:migrate` Rake task](raketasks/uploads/migrate.md). +1. Migrate any existing local uploads to the object storage using [`gitlab:uploads:migrate:all` Rake task](raketasks/uploads/migrate.md). --- @@ -188,4 +188,4 @@ _The uploads are stored by default in ``` 1. Save the file and [reconfigure GitLab](restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect. -1. Migrate any existing local uploads to the object storage using [`gitlab:uploads:migrate` Rake task](raketasks/uploads/migrate.md). +1. Migrate any existing local uploads to the object storage using [`gitlab:uploads:migrate:all` Rake task](raketasks/uploads/migrate.md). diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index c1bc231a1e7..d8072955820 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -3062,6 +3062,41 @@ type DastScannerProfileCreatePayload { } """ +Autogenerated input type of DastScannerProfileDelete +""" +input DastScannerProfileDeleteInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Full path for the project the scanner profile belongs to. + """ + fullPath: ID! + + """ + ID of the scanner profile to be deleted. + """ + id: DastScannerProfileID! +} + +""" +Autogenerated return type of DastScannerProfileDelete +""" +type DastScannerProfileDeletePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Errors encountered during execution of the mutation. + """ + errors: [String!]! +} + +""" An edge in a connection. """ type DastScannerProfileEdge { @@ -8122,6 +8157,11 @@ enum IssueType { Issue issue type """ ISSUE + + """ + Test Case issue type + """ + TEST_CASE } """ @@ -10081,6 +10121,7 @@ type Mutation { createSnippet(input: CreateSnippetInput!): CreateSnippetPayload dastOnDemandScanCreate(input: DastOnDemandScanCreateInput!): DastOnDemandScanCreatePayload dastScannerProfileCreate(input: DastScannerProfileCreateInput!): DastScannerProfileCreatePayload + dastScannerProfileDelete(input: DastScannerProfileDeleteInput!): DastScannerProfileDeletePayload dastScannerProfileUpdate(input: DastScannerProfileUpdateInput!): DastScannerProfileUpdatePayload dastSiteProfileCreate(input: DastSiteProfileCreateInput!): DastSiteProfileCreatePayload dastSiteProfileDelete(input: DastSiteProfileDeleteInput!): DastSiteProfileDeletePayload diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index eb8cad5c44c..10dfa4773d2 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -8307,6 +8307,108 @@ "possibleTypes": null }, { + "kind": "INPUT_OBJECT", + "name": "DastScannerProfileDeleteInput", + "description": "Autogenerated input type of DastScannerProfileDelete", + "fields": null, + "inputFields": [ + { + "name": "fullPath", + "description": "Full path for the project the scanner profile belongs to.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "id", + "description": "ID of the scanner profile to be deleted.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "DastScannerProfileID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DastScannerProfileDeletePayload", + "description": "Autogenerated return type of DastScannerProfileDelete", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Errors encountered during execution of the mutation.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { "kind": "OBJECT", "name": "DastScannerProfileEdge", "description": "An edge in a connection.", @@ -22433,6 +22535,12 @@ "description": "Incident issue type", "isDeprecated": false, "deprecationReason": null + }, + { + "name": "TEST_CASE", + "description": "Test Case issue type", + "isDeprecated": false, + "deprecationReason": null } ], "possibleTypes": null @@ -28874,6 +28982,33 @@ "deprecationReason": null }, { + "name": "dastScannerProfileDelete", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DastScannerProfileDeleteInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DastScannerProfileDeletePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "dastScannerProfileUpdate", "description": null, "args": [ diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index bceaf4bde4f..02357a69537 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -532,6 +532,15 @@ Autogenerated return type of DastScannerProfileCreate | `globalId` | DastScannerProfileID | ID of the scanner profile. | | `id` **{warning-solid}** | ID | **Deprecated:** Use `global_id`. Deprecated in 13.4 | +## DastScannerProfileDeletePayload + +Autogenerated return type of DastScannerProfileDelete + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Errors encountered during execution of the mutation. | + ## DastScannerProfileUpdatePayload Autogenerated return type of DastScannerProfileUpdate diff --git a/doc/api/issues.md b/doc/api/issues.md index b762698bd5b..2a0d66a8b3e 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -2079,10 +2079,10 @@ The preferred way to do this, is by using [personal access tokens](../user/profi GET /projects/:id/issues/:issue_iid/closed_by ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `issue_iid` | integer | yes | The internal ID of a project issue | +| Attribute | Type | Required | Description | +| ----------- | ---------------| -------- | ---------------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](./README.md#namespaced-path-encoding) owned by the authenticated user | +| `issue_iid` | integer | yes | The internal ID of a project issue | ```shell curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/issues/11/closed_by" diff --git a/doc/api/repository_submodules.md b/doc/api/repository_submodules.md index 9a5dcacbc2f..77f64b178f0 100644 --- a/doc/api/repository_submodules.md +++ b/doc/api/repository_submodules.md @@ -23,13 +23,13 @@ PUT /projects/:id/repository/submodules/:submodule | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | -| `submodule` | string | yes | URL encoded full path to the submodule. For example, `lib%2Fclass%2Erb` | +| `submodule` | string | yes | URL-encoded full path to the submodule. For example, `lib%2Fclass%2Erb` | | `branch` | string | yes | Name of the branch to commit into | | `commit_sha` | string | yes | Full commit SHA to update the submodule to | | `commit_message` | string | no | Commit message. If no message is provided, a default one will be set | ```shell -curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/repository/submodules/lib%2Fmodules%2Fexample" +curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/repository/submodules/lib%2Fmodules%2Fexample" \ --data "branch=master&commit_sha=3ddec28ea23acc5caa5d8331a6ecb2a65fc03e88&commit_message=Update submodule reference" ``` diff --git a/doc/ci/quick_start/README.md b/doc/ci/quick_start/README.md index 25421b067dc..2a33f09c725 100644 --- a/doc/ci/quick_start/README.md +++ b/doc/ci/quick_start/README.md @@ -80,14 +80,15 @@ so you have to pay extra attention to indentation. Always use spaces, not tabs. Below is an example for a Ruby on Rails project: ```yaml -image: "ruby:2.5" - -before_script: - - sudo apt-get update -qq && sudo apt-get install -y -qq sqlite3 libsqlite3-dev nodejs - - ruby -v - - which ruby - - gem install bundler --no-document - - bundle install --jobs $(nproc) "${FLAGS[@]}" +default: + image: ruby:2.5 + before_script: + - apt-get update + - apt-get install -y sqlite3 libsqlite3-dev nodejs + - ruby -v + - which ruby + - gem install bundler --no-document + - bundle install --jobs $(nproc) "${FLAGS[@]}" rspec: script: diff --git a/doc/integration/elasticsearch.md b/doc/integration/elasticsearch.md index 1d245f50dc4..f788c2ca5cc 100644 --- a/doc/integration/elasticsearch.md +++ b/doc/integration/elasticsearch.md @@ -160,7 +160,7 @@ After installation, be sure to [enable Elasticsearch](#enabling-elasticsearch). ## Enabling Elasticsearch NOTE: **Note:** -For large GitLab instances you can follow the instructions for [Indexing large +For GitLab instances with more than 50GB repository data you can follow the instructions for [Indexing large instances](#indexing-large-instances) below. To enable Elasticsearch, you need to have admin access to GitLab: @@ -270,164 +270,6 @@ To disable the Elasticsearch integration: bundle exec rake gitlab:elastic:delete_index RAILS_ENV=production ``` -### Indexing large instances - -CAUTION: **Warning:** -Indexing a large instance will generate a lot of Sidekiq jobs. -Make sure to prepare for this task by having a [Scalable and Highly Available -Setup](../administration/reference_architectures/index.md) or creating [extra -Sidekiq processes](../administration/operations/extra_sidekiq_processes.md) - -1. [Configure your Elasticsearch host and port](#enabling-elasticsearch). -1. Create empty indexes: - - ```shell - # Omnibus installations - sudo gitlab-rake gitlab:elastic:create_empty_index - - # Installations from source - bundle exec rake gitlab:elastic:create_empty_index RAILS_ENV=production - ``` - -1. If this is a re-index of your GitLab instance, clear the index status: - - ```shell - # Omnibus installations - sudo gitlab-rake gitlab:elastic:clear_index_status - - # Installations from source - bundle exec rake gitlab:elastic:clear_index_status RAILS_ENV=production - ``` - -1. [Enable **Elasticsearch indexing**](#enabling-elasticsearch). -1. Indexing large Git repositories can take a while. To speed up the process, you can [tune for indexing speed](https://www.elastic.co/guide/en/elasticsearch/reference/current/tune-for-indexing-speed.html#tune-for-indexing-speed): - - - You can temporarily disable [`refresh`](https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-refresh.html), the operation responsible for making changes to an index available to search. - - - You can set the number of replicas to 0. This setting controls the number of copies each primary shard of an index will have. Thus, having 0 replicas effectively disables the replication of shards across nodes, which should increase the indexing performance. This is an important trade-off in terms of reliability and query performance. It is important to remember to set the replicas to a considered value after the initial indexing is complete. - - In our experience, you can expect a 20% decrease in indexing time. After completing indexing in a later step, you can return `refresh` and `number_of_replicas` to their desired settings. - - NOTE: **Note:** - This step is optional but may help significantly speed up large indexing operations. - - ```shell - curl --request PUT localhost:9200/gitlab-production/_settings --header 'Content-Type: application/json' --data '{ - "index" : { - "refresh_interval" : "-1", - "number_of_replicas" : 0 - } }' - ``` - -1. Index projects and their associated data: - - ```shell - # Omnibus installations - sudo gitlab-rake gitlab:elastic:index_projects - - # Installations from source - bundle exec rake gitlab:elastic:index_projects RAILS_ENV=production - ``` - - This enqueues a Sidekiq job for each project that needs to be indexed. - You can view the jobs in **Admin Area > Monitoring > Background Jobs > Queues Tab** - and click `elastic_indexer`, or you can query indexing status using a Rake task: - - ```shell - # Omnibus installations - sudo gitlab-rake gitlab:elastic:index_projects_status - - # Installations from source - bundle exec rake gitlab:elastic:index_projects_status RAILS_ENV=production - - Indexing is 65.55% complete (6555/10000 projects) - ``` - - If you want to limit the index to a range of projects you can provide the - `ID_FROM` and `ID_TO` parameters: - - ```shell - # Omnibus installations - sudo gitlab-rake gitlab:elastic:index_projects ID_FROM=1001 ID_TO=2000 - - # Installations from source - bundle exec rake gitlab:elastic:index_projects ID_FROM=1001 ID_TO=2000 RAILS_ENV=production - ``` - - Where `ID_FROM` and `ID_TO` are project IDs. Both parameters are optional. - The above example will index all projects from ID `1001` up to (and including) ID `2000`. - - TIP: **Troubleshooting:** - Sometimes the project indexing jobs queued by `gitlab:elastic:index_projects` - can get interrupted. This may happen for many reasons, but it's always safe - to run the indexing task again. It will skip repositories that have - already been indexed. - - As the indexer stores the last commit SHA of every indexed repository in the - database, you can run the indexer with the special parameter `UPDATE_INDEX` and - it will check every project repository again to make sure that every commit in - a repository is indexed, which can be useful in case if your index is outdated: - - ```shell - # Omnibus installations - sudo gitlab-rake gitlab:elastic:index_projects UPDATE_INDEX=true ID_TO=1000 - - # Installations from source - bundle exec rake gitlab:elastic:index_projects UPDATE_INDEX=true ID_TO=1000 RAILS_ENV=production - ``` - - You can also use the `gitlab:elastic:clear_index_status` Rake task to force the - indexer to "forget" all progress, so it will retry the indexing process from the - start. - -1. Personal snippets are not associated with a project and need to be indexed separately: - - ```shell - # Omnibus installations - sudo gitlab-rake gitlab:elastic:index_snippets - - # Installations from source - bundle exec rake gitlab:elastic:index_snippets RAILS_ENV=production - ``` - -1. Enable replication and refreshing again after indexing (only if you previously disabled it): - - ```shell - curl --request PUT localhost:9200/gitlab-production/_settings --header 'Content-Type: application/json' --data '{ - "index" : { - "number_of_replicas" : 1, - "refresh_interval" : "1s" - } }' - ``` - - A force merge should be called after enabling the refreshing above. - - For Elasticsearch 6.x, the index should be in read-only mode before proceeding with the force merge: - - ```shell - curl --request PUT localhost:9200/gitlab-production/_settings --header 'Content-Type: application/json' --data '{ - "settings": { - "index.blocks.write": true - } }' - ``` - - Then, initiate the force merge: - - ```shell - curl --request POST 'localhost:9200/gitlab-production/_forcemerge?max_num_segments=5' - ``` - - After this, if your index is in read-only mode, switch back to read-write: - - ```shell - curl --request PUT localhost:9200/gitlab-production/_settings --header 'Content-Type: application/json' --data '{ - "settings": { - "index.blocks.write": false - } }' - ``` - -1. After the indexing has completed, enable [**Search with Elasticsearch**](#enabling-elasticsearch). - ## Zero downtime reindexing The idea behind this reindexing method is to leverage Elasticsearch index alias @@ -661,6 +503,168 @@ For basic guidance on choosing a cluster configuration you may refer to [Elastic - The `Number of Elasticsearch shards` setting usually corresponds with the number of CPUs available in your cluster. For example, if you have a 3-node cluster with 4 cores each, this means you will benefit from having at least 3*4=12 shards in the cluster. Please note, it's only possible to change the shards number by using [Split index API](https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-split-index.html) or by reindexing to a different index with a changed number of shards. - The `Number of Elasticsearch replicas` setting should most of the time be equal to `1` (each shard will have 1 replica). Using `0` is not recommended, because losing one node will corrupt the index. +### Indexing large instances + +This section may be helpful in the event that the other +[basic instructions](#enabling-elasticsearch) cause problems +due to large volumes of data being indexed. + +CAUTION: **Warning:** +Indexing a large instance will generate a lot of Sidekiq jobs. +Make sure to prepare for this task by having a [Scalable and Highly Available +Setup](../administration/reference_architectures/index.md) or creating [extra +Sidekiq processes](../administration/operations/extra_sidekiq_processes.md). + +1. [Configure your Elasticsearch host and port](#enabling-elasticsearch). +1. Create empty indexes: + + ```shell + # Omnibus installations + sudo gitlab-rake gitlab:elastic:create_empty_index + + # Installations from source + bundle exec rake gitlab:elastic:create_empty_index RAILS_ENV=production + ``` + +1. If this is a re-index of your GitLab instance, clear the index status: + + ```shell + # Omnibus installations + sudo gitlab-rake gitlab:elastic:clear_index_status + + # Installations from source + bundle exec rake gitlab:elastic:clear_index_status RAILS_ENV=production + ``` + +1. [Enable **Elasticsearch indexing**](#enabling-elasticsearch). +1. Indexing large Git repositories can take a while. To speed up the process, you can [tune for indexing speed](https://www.elastic.co/guide/en/elasticsearch/reference/current/tune-for-indexing-speed.html#tune-for-indexing-speed): + + - You can temporarily disable [`refresh`](https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-refresh.html), the operation responsible for making changes to an index available to search. + + - You can set the number of replicas to 0. This setting controls the number of copies each primary shard of an index will have. Thus, having 0 replicas effectively disables the replication of shards across nodes, which should increase the indexing performance. This is an important trade-off in terms of reliability and query performance. It is important to remember to set the replicas to a considered value after the initial indexing is complete. + + In our experience, you can expect a 20% decrease in indexing time. After completing indexing in a later step, you can return `refresh` and `number_of_replicas` to their desired settings. + + NOTE: **Note:** + This step is optional but may help significantly speed up large indexing operations. + + ```shell + curl --request PUT localhost:9200/gitlab-production/_settings --header 'Content-Type: application/json' --data '{ + "index" : { + "refresh_interval" : "-1", + "number_of_replicas" : 0 + } }' + ``` + +1. Index projects and their associated data: + + ```shell + # Omnibus installations + sudo gitlab-rake gitlab:elastic:index_projects + + # Installations from source + bundle exec rake gitlab:elastic:index_projects RAILS_ENV=production + ``` + + This enqueues a Sidekiq job for each project that needs to be indexed. + You can view the jobs in **Admin Area > Monitoring > Background Jobs > Queues Tab** + and click `elastic_indexer`, or you can query indexing status using a Rake task: + + ```shell + # Omnibus installations + sudo gitlab-rake gitlab:elastic:index_projects_status + + # Installations from source + bundle exec rake gitlab:elastic:index_projects_status RAILS_ENV=production + + Indexing is 65.55% complete (6555/10000 projects) + ``` + + If you want to limit the index to a range of projects you can provide the + `ID_FROM` and `ID_TO` parameters: + + ```shell + # Omnibus installations + sudo gitlab-rake gitlab:elastic:index_projects ID_FROM=1001 ID_TO=2000 + + # Installations from source + bundle exec rake gitlab:elastic:index_projects ID_FROM=1001 ID_TO=2000 RAILS_ENV=production + ``` + + Where `ID_FROM` and `ID_TO` are project IDs. Both parameters are optional. + The above example will index all projects from ID `1001` up to (and including) ID `2000`. + + TIP: **Troubleshooting:** + Sometimes the project indexing jobs queued by `gitlab:elastic:index_projects` + can get interrupted. This may happen for many reasons, but it's always safe + to run the indexing task again. It will skip repositories that have + already been indexed. + + As the indexer stores the last commit SHA of every indexed repository in the + database, you can run the indexer with the special parameter `UPDATE_INDEX` and + it will check every project repository again to make sure that every commit in + a repository is indexed, which can be useful in case if your index is outdated: + + ```shell + # Omnibus installations + sudo gitlab-rake gitlab:elastic:index_projects UPDATE_INDEX=true ID_TO=1000 + + # Installations from source + bundle exec rake gitlab:elastic:index_projects UPDATE_INDEX=true ID_TO=1000 RAILS_ENV=production + ``` + + You can also use the `gitlab:elastic:clear_index_status` Rake task to force the + indexer to "forget" all progress, so it will retry the indexing process from the + start. + +1. Personal snippets are not associated with a project and need to be indexed separately: + + ```shell + # Omnibus installations + sudo gitlab-rake gitlab:elastic:index_snippets + + # Installations from source + bundle exec rake gitlab:elastic:index_snippets RAILS_ENV=production + ``` + +1. Enable replication and refreshing again after indexing (only if you previously disabled it): + + ```shell + curl --request PUT localhost:9200/gitlab-production/_settings --header 'Content-Type: application/json' --data '{ + "index" : { + "number_of_replicas" : 1, + "refresh_interval" : "1s" + } }' + ``` + + A force merge should be called after enabling the refreshing above. + + For Elasticsearch 6.x, the index should be in read-only mode before proceeding with the force merge: + + ```shell + curl --request PUT localhost:9200/gitlab-production/_settings --header 'Content-Type: application/json' --data '{ + "settings": { + "index.blocks.write": true + } }' + ``` + + Then, initiate the force merge: + + ```shell + curl --request POST 'localhost:9200/gitlab-production/_forcemerge?max_num_segments=5' + ``` + + After this, if your index is in read-only mode, switch back to read-write: + + ```shell + curl --request PUT localhost:9200/gitlab-production/_settings --header 'Content-Type: application/json' --data '{ + "settings": { + "index.blocks.write": false + } }' + ``` + +1. After the indexing has completed, enable [**Search with Elasticsearch**](#enabling-elasticsearch). + ### Deleted documents Whenever a change or deletion is made to an indexed GitLab object (a merge request description is changed, a file is deleted from the master branch in a repository, a project is deleted, etc), a document in the index is deleted. However, since these are "soft" deletes, the overall number of "deleted documents", and therefore wasted space, increases. Elasticsearch does intelligent merging of segments in order to remove these deleted documents. However, depending on the amount and type of activity in your GitLab installation, it's possible to see as much as 50% wasted space in the index. diff --git a/doc/raketasks/backup_restore.md b/doc/raketasks/backup_restore.md index adbc6e151f3..7865bd180fb 100644 --- a/doc/raketasks/backup_restore.md +++ b/doc/raketasks/backup_restore.md @@ -16,7 +16,7 @@ remember to enable backups with your object storage provider if desired. ## Requirements -In order to be able to backup and restore, you need two essential tools +In order to be able to backup and restore, you need one essential tool installed on your system. - **Rsync**: If you installed GitLab: diff --git a/doc/security/README.md b/doc/security/README.md index bbc7db54b14..f8b9e423c04 100644 --- a/doc/security/README.md +++ b/doc/security/README.md @@ -12,7 +12,7 @@ type: index - [Rate limits](rate_limits.md) - [Webhooks and insecure internal web services](webhooks.md) - [Information exclusivity](information_exclusivity.md) -- [Reset your root password](reset_root_password.md) +- [Reset user password](reset_user_password.md) - [Unlock a locked user](unlock_user.md) - [User File Uploads](user_file_uploads.md) - [How we manage the CRIME vulnerability](crime_vulnerability.md) diff --git a/doc/security/reset_root_password.md b/doc/security/reset_user_password.md index cd2144698f6..bc8de882afe 100644 --- a/doc/security/reset_root_password.md +++ b/doc/security/reset_user_password.md @@ -2,9 +2,9 @@ type: howto --- -# How to reset your root password +# How to reset user password -To reset your root password, first log into your server with root privileges. +To reset the password of a user, first log into your server with root privileges. Start a Ruby on Rails console with this command: @@ -14,18 +14,22 @@ gitlab-rails console -e production Wait until the console has loaded. -There are multiple ways to find your user. You can search for email or username. +## Find the user + +There are multiple ways to find your user. You can search by email or user ID number. ```shell -user = User.where(id: 1).first +user = User.where(id: 7).first ``` or ```shell -user = User.find_by(email: 'admin@example.com') +user = User.find_by(email: 'user@example.com') ``` +## Reset the password + Now you can change your password: ```shell @@ -35,6 +39,14 @@ user.password_confirmation = 'secret_pass' It's important that you change both password and password_confirmation to make it work. +When using this method instead of the [Users API](../api/users.md#user-modification), GitLab sends an email to the user stating that the user changed their password. + +If the password was changed by an administrator, execute the following command to notify the user by email: + +```shell +user.send_only_admin_changed_your_password_notification! +``` + Don't forget to save the changes. ```shell @@ -43,6 +55,19 @@ user.save! Exit the console and try to login with your new password. +NOTE: **Note:** +Passwords can also be reset via the [Users API](../api/users.md#user-modification) + +### Reset your root password + +The steps described above can also be used to reset the root password. But first, identify the root user, with an `id` of `1`. To do so, run the following command: + +```shell +user = User.where(id: 1).first +``` + +After finding the user, follow the steps mentioned in the [Reset the password](#reset-the-password) section to reset the password of the root user. + <!-- ## Troubleshooting Include any troubleshooting steps that you can foresee. If you know beforehand what issues diff --git a/doc/user/profile/notifications.md b/doc/user/profile/notifications.md index dfa57bfb25f..6d29374596c 100644 --- a/doc/user/profile/notifications.md +++ b/doc/user/profile/notifications.md @@ -143,7 +143,8 @@ Users will be notified of the following events: | New SSH key added | User | Security email, always sent. | | New email added | User | Security email, always sent. | | Email changed | User | Security email, always sent. | -| Password changed | User | Security email, always sent. | +| Password changed | User | Security email, always sent when user changes their own password | +| Password changed by administrator | User | Security email, always sent when an adminstrator changes the password of another user | | Two-factor authentication disabled | User | Security email, always sent. | | New user created | User | Sent on user creation, except for OmniAuth (LDAP)| | User added to project | User | Sent when user is added to project | diff --git a/doc/user/search/index.md b/doc/user/search/index.md index 56ac2bfbc17..12b7b0adba9 100644 --- a/doc/user/search/index.md +++ b/doc/user/search/index.md @@ -162,6 +162,15 @@ quickly access issues and merge requests created or assigned to you within that ![search per project - shortcut](img/project_search.png) +### Autocomplete suggestions + +You can also type in this search bar to see autocomplete suggestions for: + +- Projects and groups +- Various help pages (try and type **API help**) +- Project feature pages (try and type **milestones**) +- Various settings pages (try and type **user settings**) + ## To-Do List Your [To-Do List](../todos.md#gitlab-to-do-list) can be searched by "to do" and "done". diff --git a/lib/api/users.rb b/lib/api/users.rb index aa3ecf4019d..b630ca057db 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -218,9 +218,15 @@ module API .where.not(id: user.id).exists? user_params = declared_params(include_missing: false) + admin_making_changes_for_another_user = (current_user != user) - user_params[:password_expires_at] = Time.current if user_params[:password].present? - result = ::Users::UpdateService.new(current_user, user_params.merge(user: user)).execute + if user_params[:password].present? + user_params[:password_expires_at] = Time.current if admin_making_changes_for_another_user + end + + result = ::Users::UpdateService.new(current_user, user_params.merge(user: user)).execute do |user| + user.send_only_admin_changed_your_password_notification! if admin_making_changes_for_another_user + end if result[:status] == :success present user, with: Entities::UserWithAdmin, current_user: current_user diff --git a/lib/backup/repository.rb b/lib/backup/repository.rb index 51fac9e8706..1e237d3c423 100644 --- a/lib/backup/repository.rb +++ b/lib/backup/repository.rb @@ -148,20 +148,22 @@ module Backup private def dump_consecutive - Project.find_each(batch_size: 1000) do |project| + Project.includes(:route).find_each(batch_size: 1000) do |project| dump_project(project) end end def dump_storage(storage, semaphore, max_storage_concurrency:) errors = Queue.new - queue = SizedQueue.new(1) + queue = InterlockSizedQueue.new(1) threads = Array.new(max_storage_concurrency) do Thread.new do Rails.application.executor.wrap do while project = queue.pop - semaphore.acquire + ActiveSupport::Dependencies.interlock.permit_concurrent_loads do + semaphore.acquire + end begin dump_project(project) @@ -176,7 +178,7 @@ module Backup end end - Project.for_repository_storage(storage).find_each(batch_size: 100) do |project| + Project.for_repository_storage(storage).includes(:route).find_each(batch_size: 100) do |project| break unless errors.empty? queue.push(project) @@ -241,5 +243,23 @@ module Backup pool.schedule end end + + class InterlockSizedQueue < SizedQueue + extend ::Gitlab::Utils::Override + + override :pop + def pop(*) + ActiveSupport::Dependencies.interlock.permit_concurrent_loads do + super + end + end + + override :push + def push(*) + ActiveSupport::Dependencies.interlock.permit_concurrent_loads do + super + end + end + end end end diff --git a/lib/gitlab/ci/features.rb b/lib/gitlab/ci/features.rb index 206ba029750..ab70ba5e17e 100644 --- a/lib/gitlab/ci/features.rb +++ b/lib/gitlab/ci/features.rb @@ -56,8 +56,11 @@ module Gitlab ::Feature.enabled?(:ci_if_parenthesis_enabled, default_enabled: true) end - def self.allow_to_create_merge_request_pipelines_in_target_project?(target_project) - ::Feature.enabled?(:ci_allow_to_create_merge_request_pipelines_in_target_project, target_project, default_enabled: true) + # NOTE: The feature flag `disallow_to_create_merge_request_pipelines_in_target_project` + # is a safe switch to disable the feature for a parituclar project when something went wrong, + # therefore it's not supposed to be enabled by default. + def self.disallow_to_create_merge_request_pipelines_in_target_project?(target_project) + ::Feature.enabled?(:ci_disallow_to_create_merge_request_pipelines_in_target_project, target_project) end def self.ci_plan_needs_size_limit?(project) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index da902413079..14a50c8d5ef 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2540,6 +2540,9 @@ msgstr "" msgid "An %{link_start}alert%{link_end} with the same fingerprint is already open. To change the status of this alert, resolve the linked alert." msgstr "" +msgid "An administrator changed the password for your GitLab account on %{link_to}." +msgstr "" + msgid "An alert has been triggered in %{project_path}." msgstr "" @@ -12565,6 +12568,9 @@ msgstr "" msgid "Hello there" msgstr "" +msgid "Hello, %{username}!" +msgstr "" + msgid "Help" msgstr "" @@ -18304,6 +18310,9 @@ msgstr "" msgid "Please complete your profile with email address" msgstr "" +msgid "Please contact your administrator with any questions." +msgstr "" + msgid "Please contact your administrator." msgstr "" @@ -24329,6 +24338,9 @@ msgstr "" msgid "Test Cases" msgstr "" +msgid "Test cases are not available for this project" +msgstr "" + msgid "Test coverage parsing" msgstr "" diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index 30fa991190a..e4cdcda756b 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -286,82 +286,111 @@ RSpec.describe Admin::UsersController do describe 'POST update' do context 'when the password has changed' do - def update_password(user, password, password_confirmation = nil) + def update_password(user, password = User.random_password, password_confirmation = password) params = { id: user.to_param, user: { password: password, - password_confirmation: password_confirmation || password + password_confirmation: password_confirmation } } post :update, params: params end - context 'when the admin changes their own password' do - it 'updates the password' do - expect { update_password(admin, 'AValidPassword1') } - .to change { admin.reload.encrypted_password } - end + context 'when admin changes their own password' do + context 'when password is valid' do + it 'updates the password' do + expect { update_password(admin) } + .to change { admin.reload.encrypted_password } + end + + it 'does not set the new password to expire immediately' do + expect { update_password(admin) } + .not_to change { admin.reload.password_expired? } + end + + it 'does not enqueue the `admin changed your password` email' do + expect { update_password(admin) } + .not_to have_enqueued_mail(DeviseMailer, :password_change_by_admin) + end - it 'does not set the new password to expire immediately' do - expect { update_password(admin, 'AValidPassword1') } - .not_to change { admin.reload.password_expires_at } + it 'enqueues the `password changed` email' do + expect { update_password(admin) } + .to have_enqueued_mail(DeviseMailer, :password_change) + end end end - context 'when the new password is valid' do - it 'redirects to the user' do - update_password(user, 'AValidPassword1') + context 'when admin changes the password of another user' do + context 'when the new password is valid' do + it 'redirects to the user' do + update_password(user) - expect(response).to redirect_to(admin_user_path(user)) - end + expect(response).to redirect_to(admin_user_path(user)) + end - it 'updates the password' do - expect { update_password(user, 'AValidPassword1') } - .to change { user.reload.encrypted_password } - end + it 'updates the password' do + expect { update_password(user) } + .to change { user.reload.encrypted_password } + end - it 'sets the new password to expire immediately' do - expect { update_password(user, 'AValidPassword1') } - .to change { user.reload.password_expires_at }.to be_within(2.seconds).of(Time.current) + it 'sets the new password to expire immediately' do + expect { update_password(user) } + .to change { user.reload.password_expired? }.from(false).to(true) + end + + it 'enqueues the `admin changed your password` email' do + expect { update_password(user) } + .to have_enqueued_mail(DeviseMailer, :password_change_by_admin) + end + + it 'does not enqueue the `password changed` email' do + expect { update_password(user) } + .not_to have_enqueued_mail(DeviseMailer, :password_change) + end end end context 'when the new password is invalid' do + let(:password) { 'invalid' } + it 'shows the edit page again' do - update_password(user, 'invalid') + update_password(user, password) expect(response).to render_template(:edit) end it 'returns the error message' do - update_password(user, 'invalid') + update_password(user, password) expect(assigns[:user].errors).to contain_exactly(a_string_matching(/too short/)) end it 'does not update the password' do - expect { update_password(user, 'invalid') } + expect { update_password(user, password) } .not_to change { user.reload.encrypted_password } end end context 'when the new password does not match the password confirmation' do + let(:password) { 'some_password' } + let(:password_confirmation) { 'not_same_as_password' } + it 'shows the edit page again' do - update_password(user, 'AValidPassword1', 'AValidPassword2') + update_password(user, password, password_confirmation) expect(response).to render_template(:edit) end it 'returns the error message' do - update_password(user, 'AValidPassword1', 'AValidPassword2') + update_password(user, password, password_confirmation) expect(assigns[:user].errors).to contain_exactly(a_string_matching(/doesn't match/)) end it 'does not update the password' do - expect { update_password(user, 'AValidPassword1', 'AValidPassword2') } + expect { update_password(user, password, password_confirmation) } .not_to change { user.reload.encrypted_password } end end diff --git a/spec/controllers/profiles/notifications_controller_spec.rb b/spec/controllers/profiles/notifications_controller_spec.rb index cd6eb929b19..90df7cc0991 100644 --- a/spec/controllers/profiles/notifications_controller_spec.rb +++ b/spec/controllers/profiles/notifications_controller_spec.rb @@ -37,7 +37,7 @@ RSpec.describe Profiles::NotificationsController do expect(assigns(:group_notifications).map(&:source_id)).to include(subgroup.id) end - it 'has an N+1 (but should not)' do + it 'does not have an N+1' do sign_in(user) control = ActiveRecord::QueryRecorder.new do @@ -46,10 +46,9 @@ RSpec.describe Profiles::NotificationsController do create_list(:group, 2, parent: group) - # We currently have an N + 1, switch to `not_to` once fixed expect do get :show - end.to exceed_query_limit(control) + end.not_to exceed_query_limit(control) end end @@ -62,7 +61,7 @@ RSpec.describe Profiles::NotificationsController do before do group.add_developer(user) sign_in(user) - stub_const('Profiles::NotificationsController::NOTIFICATIONS_PER_PAGE', notifications_per_page) + allow(Kaminari.config).to receive(:default_per_page).and_return(notifications_per_page) end it 'paginates the groups' do diff --git a/spec/factories/issues.rb b/spec/factories/issues.rb index 5c62de4d08d..90e43b9e22c 100644 --- a/spec/factories/issues.rb +++ b/spec/factories/issues.rb @@ -52,5 +52,9 @@ FactoryBot.define do factory :incident do issue_type { :incident } end + + factory :quality_test_case do + issue_type { :test_case } + end end end diff --git a/spec/features/issues/incident_issue_spec.rb b/spec/features/issues/incident_issue_spec.rb index 57dfb370bf4..d004ee85dd8 100644 --- a/spec/features/issues/incident_issue_spec.rb +++ b/spec/features/issues/incident_issue_spec.rb @@ -3,17 +3,14 @@ require 'spec_helper' RSpec.describe 'Incident Detail', :js do - let(:user) { create(:user) } - let(:project) { create(:project, :public) } - let(:incident) { create(:issue, project: project, author: user, issue_type: 'incident', description: 'hello') } - context 'when user displays the incident' do - before do + it 'shows the incident tabs' do + project = create(:project, :public) + incident = create(:incident, project: project, description: 'hello') + visit project_issue_path(project, incident) wait_for_requests - end - it 'shows the incident tabs' do page.within('.issuable-details') do incident_tabs = find('[data-testid="incident-tabs"]') diff --git a/spec/features/merge_request/user_sees_pipelines_spec.rb b/spec/features/merge_request/user_sees_pipelines_spec.rb index 5d41e49c478..8e15ba6cf8d 100644 --- a/spec/features/merge_request/user_sees_pipelines_spec.rb +++ b/spec/features/merge_request/user_sees_pipelines_spec.rb @@ -123,6 +123,10 @@ RSpec.describe 'Merge request > User sees pipelines', :js do context 'when actor is a developer in parent project' do let(:actor) { developer_in_parent } + before do + stub_feature_flags(ci_disallow_to_create_merge_request_pipelines_in_target_project: false) + end + it 'creates a pipeline in the parent project when user proceeds with the warning' do visit project_merge_request_path(parent_project, merge_request) diff --git a/spec/finders/user_group_notification_settings_finder_spec.rb b/spec/finders/user_group_notification_settings_finder_spec.rb new file mode 100644 index 00000000000..6f391621999 --- /dev/null +++ b/spec/finders/user_group_notification_settings_finder_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe UserGroupNotificationSettingsFinder do + let_it_be(:user) { create(:user) } + + subject { described_class.new(user, Group.where(id: groups.map(&:id))).execute } + + def attributes(&proc) + subject.map(&proc).uniq + end + + context 'when the groups have no existing notification settings' do + context 'when the groups have no ancestors' do + let_it_be(:groups) { create_list(:group, 3) } + + it 'will be a default Global notification setting', :aggregate_failures do + expect(subject.count).to eq(3) + expect(attributes(&:notification_email)).to eq([nil]) + expect(attributes(&:level)).to eq(['global']) + end + end + + context 'when the groups have ancestors' do + context 'when an ancestor has a level other than Global' do + let_it_be(:ancestor_a) { create(:group) } + let_it_be(:group_a) { create(:group, parent: ancestor_a) } + let_it_be(:ancestor_b) { create(:group) } + let_it_be(:group_b) { create(:group, parent: ancestor_b) } + let_it_be(:email) { create(:email, :confirmed, email: 'ancestor@example.com', user: user) } + + let_it_be(:groups) { [group_a, group_b] } + + before do + create(:notification_setting, user: user, source: ancestor_a, level: 'participating', notification_email: email.email) + create(:notification_setting, user: user, source: ancestor_b, level: 'participating', notification_email: email.email) + end + + it 'has the same level set' do + expect(attributes(&:level)).to eq(['participating']) + end + + it 'has the same email set' do + expect(attributes(&:notification_email)).to eq(['ancestor@example.com']) + end + + it 'only returns the two queried groups' do + expect(subject.count).to eq(2) + end + end + + context 'when an ancestor has a Global level but has an email set' do + let_it_be(:grand_ancestor) { create(:group) } + let_it_be(:ancestor) { create(:group, parent: grand_ancestor) } + let_it_be(:group) { create(:group, parent: ancestor) } + let_it_be(:ancestor_email) { create(:email, :confirmed, email: 'ancestor@example.com', user: user) } + let_it_be(:grand_email) { create(:email, :confirmed, email: 'grand@example.com', user: user) } + + let_it_be(:groups) { [group] } + + before do + create(:notification_setting, user: user, source: grand_ancestor, level: 'participating', notification_email: grand_email.email) + create(:notification_setting, user: user, source: ancestor, level: 'global', notification_email: ancestor_email.email) + end + + it 'has the same email and level set', :aggregate_failures do + expect(subject.count).to eq(1) + expect(attributes(&:level)).to eq(['global']) + expect(attributes(&:notification_email)).to eq(['ancestor@example.com']) + end + end + + it 'does not cause an N+1', :aggregate_failures do + parent = create(:group) + child = create(:group, parent: parent) + + control = ActiveRecord::QueryRecorder.new do + described_class.new(user, Group.where(id: child.id)).execute + end + + other_parent = create(:group) + other_children = create_list(:group, 2, parent: other_parent) + + result = nil + + expect do + result = described_class.new(user, Group.where(id: other_children.append(child).map(&:id))).execute + end.not_to exceed_query_limit(control) + + expect(result.count).to eq(3) + end + end + end +end 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 14ffd7b2d85..4869e75a2f3 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,4 +1,11 @@ -import * as filteredSearchUtils from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; +import { + stripQuotes, + uniqueTokens, + prepareTokens, + processFilters, + filterToQueryObject, + urlQueryToFilter, +} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; import { tokenValueAuthor, @@ -20,7 +27,7 @@ describe('Filtered Search Utils', () => { `( 'returns string $outputValue when called with string $inputValue', ({ inputValue, outputValue }) => { - expect(filteredSearchUtils.stripQuotes(inputValue)).toBe(outputValue); + expect(stripQuotes(inputValue)).toBe(outputValue); }, ); }); @@ -28,7 +35,7 @@ describe('Filtered Search Utils', () => { describe('uniqueTokens', () => { it('returns tokens array with duplicates removed', () => { expect( - filteredSearchUtils.uniqueTokens([ + uniqueTokens([ tokenValueAuthor, tokenValueLabel, tokenValueMilestone, @@ -40,13 +47,172 @@ describe('Filtered Search Utils', () => { it('returns tokens array as it is if it does not have duplicates', () => { expect( - filteredSearchUtils.uniqueTokens([ - tokenValueAuthor, - tokenValueLabel, - tokenValueMilestone, - tokenValuePlain, - ]), + uniqueTokens([tokenValueAuthor, tokenValueLabel, tokenValueMilestone, tokenValuePlain]), ).toHaveLength(4); }); }); }); + +describe('prepareTokens', () => { + describe('with empty data', () => { + it('returns an empty array', () => { + expect(prepareTokens()).toEqual([]); + expect(prepareTokens({})).toEqual([]); + expect(prepareTokens({ milestone: null, author: null, assignees: [], labels: [] })).toEqual( + [], + ); + }); + }); + + it.each([ + [ + 'milestone', + { value: 'v1.0', operator: '=' }, + [{ type: 'milestone', value: { data: 'v1.0', operator: '=' } }], + ], + [ + 'author', + { value: 'mr.popo', operator: '!=' }, + [{ type: 'author', value: { data: 'mr.popo', operator: '!=' } }], + ], + [ + 'labels', + [{ value: 'z-fighters', operator: '=' }], + [{ type: 'labels', value: { data: 'z-fighters', operator: '=' } }], + ], + [ + 'assignees', + [{ value: 'krillin', operator: '=' }, { value: 'piccolo', operator: '!=' }], + [ + { type: 'assignees', value: { data: 'krillin', operator: '=' } }, + { type: 'assignees', value: { data: 'piccolo', operator: '!=' } }, + ], + ], + [ + 'foo', + [{ value: 'bar', operator: '!=' }, { value: 'baz', operator: '!=' }], + [ + { type: 'foo', value: { data: 'bar', operator: '!=' } }, + { type: 'foo', value: { data: 'baz', operator: '!=' } }, + ], + ], + ])('gathers %s=%j into result=%j', (token, value, result) => { + const res = prepareTokens({ [token]: value }); + expect(res).toEqual(result); + }); +}); + +describe('processFilters', () => { + it('processes multiple filter values', () => { + const result = processFilters([ + { type: 'foo', value: { data: 'foo', operator: '=' } }, + { type: 'bar', value: { data: 'bar1', operator: '=' } }, + { type: 'bar', value: { data: 'bar2', operator: '!=' } }, + ]); + + expect(result).toStrictEqual({ + foo: [{ value: 'foo', operator: '=' }], + bar: [{ value: 'bar1', operator: '=' }, { value: 'bar2', operator: '!=' }], + }); + }); + + it('does not remove wrapping double quotes from the data', () => { + const result = processFilters([ + { type: 'foo', value: { data: '"value with spaces"', operator: '=' } }, + ]); + + expect(result).toStrictEqual({ + foo: [{ value: '"value with spaces"', operator: '=' }], + }); + }); +}); + +describe('filterToQueryObject', () => { + describe('with empty data', () => { + it('returns an empty object', () => { + expect(filterToQueryObject()).toEqual({}); + expect(filterToQueryObject({})).toEqual({}); + expect(filterToQueryObject({ author_username: null, label_name: [] })).toEqual({ + author_username: null, + label_name: null, + 'not[author_username]': null, + 'not[label_name]': null, + }); + }); + }); + + it.each([ + [ + 'author_username', + { value: 'v1.0', operator: '=' }, + { author_username: 'v1.0', 'not[author_username]': null }, + ], + [ + 'author_username', + { value: 'v1.0', operator: '!=' }, + { author_username: null, 'not[author_username]': 'v1.0' }, + ], + [ + 'label_name', + [{ value: 'z-fighters', operator: '=' }], + { label_name: ['z-fighters'], 'not[label_name]': null }, + ], + [ + 'label_name', + [{ value: 'z-fighters', operator: '!=' }], + { label_name: null, 'not[label_name]': ['z-fighters'] }, + ], + [ + 'foo', + [{ value: 'bar', operator: '=' }, { value: 'baz', operator: '=' }], + { foo: ['bar', 'baz'], 'not[foo]': null }, + ], + [ + 'foo', + [{ value: 'bar', operator: '!=' }, { value: 'baz', operator: '!=' }], + { foo: null, 'not[foo]': ['bar', 'baz'] }, + ], + [ + 'foo', + [{ value: 'bar', operator: '!=' }, { value: 'baz', operator: '=' }], + { foo: ['baz'], 'not[foo]': ['bar'] }, + ], + ])('gathers filter values %s=%j into query object=%j', (token, value, result) => { + const res = filterToQueryObject({ [token]: value }); + expect(res).toEqual(result); + }); +}); + +describe('urlQueryToFilter', () => { + describe('with empty data', () => { + it('returns an empty object', () => { + expect(urlQueryToFilter()).toEqual({}); + expect(urlQueryToFilter('')).toEqual({}); + expect(urlQueryToFilter('author_username=&milestone_title=&')).toEqual({}); + }); + }); + + it.each([ + ['author_username=v1.0', { author_username: { value: 'v1.0', operator: '=' } }], + ['not[author_username]=v1.0', { author_username: { value: 'v1.0', operator: '!=' } }], + ['foo=bar&foo=baz', { foo: { value: 'baz', operator: '=' } }], + ['foo=bar&foo[]=baz', { foo: [{ value: 'baz', operator: '=' }] }], + ['not[foo]=bar&foo=baz', { foo: { value: 'baz', operator: '=' } }], + [ + 'foo[]=bar&foo[]=baz¬[foo]=', + { foo: [{ value: 'bar', operator: '=' }, { value: 'baz', operator: '=' }] }, + ], + [ + 'foo[]=¬[foo][]=bar¬[foo][]=baz', + { foo: [{ value: 'bar', operator: '!=' }, { value: 'baz', operator: '!=' }] }, + ], + [ + 'foo[]=baz¬[foo][]=bar', + { foo: [{ value: 'baz', operator: '=' }, { value: 'bar', operator: '!=' }] }, + ], + ['not[foo][]=bar', { foo: [{ value: 'bar', operator: '!=' }] }], + ])('gathers filter values %s into query object=%j', (query, result) => { + const res = urlQueryToFilter(query); + expect(res).toEqual(result); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js index 812aa2184ec..48653bce0fd 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js @@ -17,6 +17,10 @@ describe('rich_content_editor/services/html_to_markdown_renderer', () => { fakeNode = { nodeValue: 'mock_node', dataset: {} }; }); + afterEach(() => { + htmlToMarkdownRenderer = null; + }); + describe('TEXT_NODE visitor', () => { it('composes getSpaceControlled, getSpaceCollapsedText, and trim services', () => { htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); @@ -157,4 +161,30 @@ describe('rich_content_editor/services/html_to_markdown_renderer', () => { expect(htmlToMarkdownRenderer['H1, H2, H3, H4, H5, H6'](fakeNode, heading)).toBe(result); }); }); + + describe('PRE CODE', () => { + let node; + const subContent = 'sub content'; + const originalConverterResult = 'base result'; + + beforeEach(() => { + node = document.createElement('PRE'); + + node.innerText = 'reference definition content'; + node.dataset.sseReferenceDefinition = true; + + baseRenderer.convert.mockReturnValueOnce(originalConverterResult); + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); + }); + + it('returns raw text when pre node has sse-reference-definitions class', () => { + expect(htmlToMarkdownRenderer['PRE CODE'](node, subContent)).toBe(`\n\n${node.innerText}\n`); + }); + + it('returns base result when pre node does not have sse-reference-definitions class', () => { + delete node.dataset.sseReferenceDefinition; + + expect(htmlToMarkdownRenderer['PRE CODE'](node, subContent)).toBe(originalConverterResult); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js index f4a06b91a10..b3d9576f38b 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js @@ -1,5 +1,4 @@ import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph'; -import { renderUneditableBranch } from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; import { buildMockTextNode } from './mock_data'; @@ -17,7 +16,7 @@ const identifierParagraphNode = buildMockParagraphNode( `[another-identifier]: https://example.com "This example has a title" [identifier]: http://example1.com [this link]: http://example2.com`, ); -describe('Render Identifier Paragraph renderer', () => { +describe('rich_content_editor/renderers_render_identifier_paragraph', () => { describe('canRender', () => { it.each` node | paragraph | target @@ -37,8 +36,49 @@ describe('Render Identifier Paragraph renderer', () => { }); describe('render', () => { - it('should delegate rendering to the renderUneditableBranch util', () => { - expect(renderer.render).toBe(renderUneditableBranch); + let context; + let result; + + beforeEach(() => { + const node = { + firstChild: { + type: 'text', + literal: '[Some text]: https://link.com', + next: { + type: 'linebreak', + next: { + type: 'text', + literal: '[identifier]: http://example1.com "title"', + }, + }, + }, + }; + context = { skipChildren: jest.fn() }; + result = renderer.render(node, context); + }); + + it('renders the reference definitions as a code block', () => { + expect(result).toEqual([ + { + type: 'openTag', + tagName: 'pre', + classNames: ['code-block', 'language-markdown'], + attributes: { + 'data-sse-reference-definition': true, + }, + }, + { type: 'openTag', tagName: 'code' }, + { + type: 'text', + content: '[Some text]: https://link.com\n[identifier]: http://example1.com "title"', + }, + { type: 'closeTag', tagName: 'code' }, + { type: 'closeTag', tagName: 'pre' }, + ]); + }); + + it('skips the reference definition node children from rendering', () => { + expect(context.skipChildren).toHaveBeenCalled(); }); }); }); diff --git a/spec/helpers/emails_helper_spec.rb b/spec/helpers/emails_helper_spec.rb index 4af81cf83ac..96ac4015c77 100644 --- a/spec/helpers/emails_helper_spec.rb +++ b/spec/helpers/emails_helper_spec.rb @@ -118,6 +118,14 @@ RSpec.describe EmailsHelper do end end + describe '#say_hello' do + let(:user) { build(:user, name: 'John') } + + it 'returns the greeting message for the given user' do + expect(say_hello(user)).to eq('Hello, John!') + end + end + describe '#two_factor_authentication_disabled_text' do it 'returns the message that 2FA is disabled' do expect(two_factor_authentication_disabled_text).to eq( @@ -145,6 +153,33 @@ RSpec.describe EmailsHelper do end end + describe '#admin_changed_password_text' do + context 'format is html' do + it 'returns HTML' do + expect(admin_changed_password_text(format: :html)).to eq( + "An administrator changed the password for your GitLab account on " \ + "#{link_to(Gitlab.config.gitlab.url, Gitlab.config.gitlab.url, target: :_blank, rel: 'noopener noreferrer')}." + ) + end + end + + context 'format is not specified' do + it 'returns text' do + expect(admin_changed_password_text).to eq( + "An administrator changed the password for your GitLab account on #{Gitlab.config.gitlab.url}." + ) + end + end + end + + describe '#contact_your_administrator_text' do + it 'returns the message to contact the administrator' do + expect(contact_your_administrator_text).to eq( + _('Please contact your administrator with any questions.') + ) + end + end + describe 'password_reset_token_valid_time' do def validate_time_string(time_limit, expected_string) Devise.reset_password_within = time_limit diff --git a/spec/lib/backup/repository_spec.rb b/spec/lib/backup/repository_spec.rb index c4ad239f9d7..b8b0d66d8d8 100644 --- a/spec/lib/backup/repository_spec.rb +++ b/spec/lib/backup/repository_spec.rb @@ -47,15 +47,27 @@ RSpec.describe Backup::Repository do end it 'project query raises an error' do - allow(Project).to receive(:find_each).and_raise(ActiveRecord::StatementTimeout) + allow(Project).to receive_message_chain(:includes, :find_each).and_raise(ActiveRecord::StatementTimeout) expect { subject.dump(max_concurrency: 1, max_storage_concurrency: 1) }.to raise_error(ActiveRecord::StatementTimeout) end end + + it 'avoids N+1 database queries' do + control_count = ActiveRecord::QueryRecorder.new do + subject.dump(max_concurrency: 1, max_storage_concurrency: 1) + end.count + + create_list(:project, 2, :wiki_repo) + + expect do + subject.dump(max_concurrency: 1, max_storage_concurrency: 1) + end.not_to exceed_query_limit(control_count) + end end [4, 10].each do |max_storage_concurrency| - context "max_storage_concurrency #{max_storage_concurrency}", quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/241701' do + context "max_storage_concurrency #{max_storage_concurrency}" do it 'creates the expected number of threads' do expect(Thread).to receive(:new) .exactly(storage_keys.length * (max_storage_concurrency + 1)).times @@ -89,7 +101,7 @@ RSpec.describe Backup::Repository do end it 'project query raises an error' do - allow(Project).to receive_message_chain('for_repository_storage.find_each').and_raise(ActiveRecord::StatementTimeout) + allow(Project).to receive_message_chain(:for_repository_storage, :includes, :find_each).and_raise(ActiveRecord::StatementTimeout) expect { subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) }.to raise_error(ActiveRecord::StatementTimeout) end @@ -102,6 +114,18 @@ RSpec.describe Backup::Repository do end end end + + it 'avoids N+1 database queries' do + control_count = ActiveRecord::QueryRecorder.new do + subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) + end.count + + create_list(:project, 2, :wiki_repo) + + expect do + subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) + end.not_to exceed_query_limit(control_count) + end end end end diff --git a/spec/mailers/devise_mailer_spec.rb b/spec/mailers/devise_mailer_spec.rb index 4637df9c8a3..2ee15308400 100644 --- a/spec/mailers/devise_mailer_spec.rb +++ b/spec/mailers/devise_mailer_spec.rb @@ -4,6 +4,9 @@ require 'spec_helper' require 'email_spec' RSpec.describe DeviseMailer do + include EmailSpec::Matchers + include_context 'gitlab email notification' + describe "#confirmation_instructions" do subject { described_class.confirmation_instructions(user, 'faketoken', {}) } @@ -35,4 +38,30 @@ RSpec.describe DeviseMailer do end end end + + describe '#password_change_by_admin' do + subject { described_class.password_change_by_admin(user) } + + let_it_be(:user) { create(:user) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like 'a user cannot unsubscribe through footer link' + + it 'is sent to the user' do + is_expected.to deliver_to user.email + end + + it 'has the correct subject' do + is_expected.to have_subject /^Password changed by administrator$/i + end + + it 'includes the correct content' do + is_expected.to have_body_text /An administrator changed the password for your GitLab account/ + end + + it 'includes a link to GitLab' do + is_expected.to have_body_text /#{Gitlab.config.gitlab.url}/ + end + end end diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 0b9c06d9737..f869b586fbe 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -133,6 +133,7 @@ RSpec.describe Issue do let_it_be(:project) { create(:project) } let_it_be(:issue) { create(:issue, project: project) } let_it_be(:incident) { create(:incident, project: project) } + let_it_be(:test_case) { create(:quality_test_case, project: project) } it 'gives issues with the given issue type' do expect(described_class.with_issue_type('issue')) @@ -140,8 +141,8 @@ RSpec.describe Issue do end it 'gives issues with the given issue type' do - expect(described_class.with_issue_type(%w(issue incident))) - .to contain_exactly(issue, incident) + expect(described_class.with_issue_type(%w(issue incident test_case))) + .to contain_exactly(issue, incident, test_case) end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 74a36daa727..3733e47334a 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -180,6 +180,58 @@ RSpec.describe User do end.to have_enqueued_job.on_queue('mailers').exactly(:twice) end end + + context 'emails sent on changing password' do + context 'when password is updated' do + context 'default behaviour' do + it 'enqueues the `password changed` email' do + user.password = User.random_password + + expect { user.save! }.to have_enqueued_mail(DeviseMailer, :password_change) + end + + it 'does not enqueue the `admin changed your password` email' do + user.password = User.random_password + + expect { user.save! }.not_to have_enqueued_mail(DeviseMailer, :password_change_by_admin) + end + end + + context '`admin changed your password` email' do + it 'is enqueued only when explicitly allowed' do + user.password = User.random_password + user.send_only_admin_changed_your_password_notification! + + expect { user.save! }.to have_enqueued_mail(DeviseMailer, :password_change_by_admin) + end + + it '`password changed` email is not enqueued if it is explicitly allowed' do + user.password = User.random_password + user.send_only_admin_changed_your_password_notification! + + expect { user.save! }.not_to have_enqueued_mail(DeviseMailer, :password_changed) + end + + it 'is not enqueued if sending notifications on password updates is turned off as per Devise config' do + user.password = User.random_password + user.send_only_admin_changed_your_password_notification! + + allow(Devise).to receive(:send_password_change_notification).and_return(false) + + expect { user.save! }.not_to have_enqueued_mail(DeviseMailer, :password_change_by_admin) + end + end + end + + context 'when password is not updated' do + it 'does not enqueue the `admin changed your password` email even if explicitly allowed' do + user.name = 'John' + user.send_only_admin_changed_your_password_notification! + + expect { user.save! }.not_to have_enqueued_mail(DeviseMailer, :password_change_by_admin) + end + end + end end describe 'validations' do @@ -4491,6 +4543,44 @@ RSpec.describe User do end end + describe '#notification_settings_for_groups' do + let_it_be(:user) { create(:user) } + let_it_be(:groups) { create_list(:group, 2) } + + subject { user.notification_settings_for_groups(arg) } + + before do + groups.each do |group| + group.add_maintainer(user) + end + end + + shared_examples_for 'notification_settings_for_groups method' do + it 'returns NotificationSetting objects for provided groups', :aggregate_failures do + expect(subject.count).to eq(groups.count) + expect(subject.map(&:source_id)).to match_array(groups.map(&:id)) + end + end + + context 'when given an ActiveRecord relationship' do + let_it_be(:arg) { Group.where(id: groups.map(&:id)) } + + it_behaves_like 'notification_settings_for_groups method' + + it 'uses #select to maintain lazy querying behavior' do + expect(arg).to receive(:select).and_call_original + + subject + end + end + + context 'when given an Array of Groups' do + let_it_be(:arg) { groups } + + it_behaves_like 'notification_settings_for_groups method' + end + end + describe '#notification_email_for' do let(:user) { create(:user) } let(:group) { create(:group) } diff --git a/spec/requests/api/graphql/project/issues_spec.rb b/spec/requests/api/graphql/project/issues_spec.rb index ceb32476f7d..89b3ecdabb9 100644 --- a/spec/requests/api/graphql/project/issues_spec.rb +++ b/spec/requests/api/graphql/project/issues_spec.rb @@ -292,4 +292,65 @@ RSpec.describe 'getting an issue list for a project' do expect(alert_titles).to contain_exactly(*expected_titles) end end + + context 'fetching labels' do + let(:fields) do + <<~QUERY + edges { + node { + id + labels { + nodes { + id + } + } + } + } + QUERY + end + + let(:query) do + graphql_query_for( + 'project', + { 'fullPath' => project.full_path }, + query_graphql_field('issues', {}, fields) + ) + end + + let_it_be(:project) { create(:project, :public) } + let_it_be(:label1) { create(:label, project: project) } + let_it_be(:label2) { create(:label, project: project) } + let_it_be(:issue1) { create(:issue, project: project, labels: [label1]) } + let_it_be(:issue2) { create(:issue, project: project, labels: [label2]) } + let_it_be(:issues) { [issue1, issue2] } + + before do + stub_feature_flags(graphql_lookahead_support: true) + end + + def response_label_ids(response_data) + response_data.map do |edge| + edge['node']['labels']['nodes'].map { |u| u['id'] } + end.flatten + end + + def labels_as_global_ids(issues) + issues.map(&:labels).flatten.map(&:to_global_id).map(&:to_s) + end + + it 'avoids N+1 queries', :aggregate_failures do + control = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) } + expect(issues_data.count).to eq(2) + expect(response_label_ids(issues_data)).to match_array(labels_as_global_ids(issues)) + + issues.append create(:issue, project: project, labels: [create(:label, project: project)]) + + expect { post_graphql(query, current_user: current_user) }.not_to exceed_query_limit(control) + # graphql_data is memoized (see spec/support/helpers/graphql_helpers.rb:271) + # so we have to parse the body ourselves the second time + response_data = Gitlab::Json.parse(response.body)['data']['project']['issues']['edges'] + expect(response_data.count).to eq(3) + expect(response_label_ids(response_data)).to match_array(labels_as_global_ids(issues)) + end + end end diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 39cff2ad74e..806b586ef49 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -914,6 +914,50 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do expect(response).to have_gitlab_http_status(:ok) end + context 'updating password' do + def update_password(user, admin, password = User.random_password) + put api("/users/#{user.id}", admin), params: { password: password } + end + + context 'admin updates their own password' do + it 'does not force reset on next login' do + update_password(admin, admin) + + expect(response).to have_gitlab_http_status(:ok) + expect(user.reload.password_expired?).to eq(false) + end + + it 'does not enqueue the `admin changed your password` email' do + expect { update_password(admin, admin) } + .not_to have_enqueued_mail(DeviseMailer, :password_change_by_admin) + end + + it 'enqueues the `password changed` email' do + expect { update_password(admin, admin) } + .to have_enqueued_mail(DeviseMailer, :password_change) + end + end + + context 'admin updates the password of another user' do + it 'forces reset on next login' do + update_password(user, admin) + + expect(response).to have_gitlab_http_status(:ok) + expect(user.reload.password_expired?).to eq(true) + end + + it 'enqueues the `admin changed your password` email' do + expect { update_password(user, admin) } + .to have_enqueued_mail(DeviseMailer, :password_change_by_admin) + end + + it 'does not enqueue the `password changed` email' do + expect { update_password(user, admin) } + .not_to have_enqueued_mail(DeviseMailer, :password_change) + end + end + end + it "updates user with new bio" do put api("/users/#{user.id}", admin), params: { bio: 'new test bio' } @@ -940,13 +984,6 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do expect(user.reload.bio).to eq('') end - it "updates user with new password and forces reset on next login" do - put api("/users/#{user.id}", admin), params: { password: '12345678' } - - expect(response).to have_gitlab_http_status(:ok) - expect(user.reload.password_expires_at).to be <= Time.now - end - it "updates user with organization" do put api("/users/#{user.id}", admin), params: { organization: 'GitLab' } @@ -1397,7 +1434,7 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do end end - describe 'POST /users/:id/keys' do + describe 'POST /users/:id/gpg_keys' do it 'does not create invalid GPG key' do post api("/users/#{user.id}/gpg_keys", admin) diff --git a/spec/requests/profiles/notifications_controller_spec.rb b/spec/requests/profiles/notifications_controller_spec.rb index 633375de6aa..d162f5b220d 100644 --- a/spec/requests/profiles/notifications_controller_spec.rb +++ b/spec/requests/profiles/notifications_controller_spec.rb @@ -25,8 +25,7 @@ RSpec.describe 'view user notifications' do end describe 'GET /profile/notifications' do - # To be fixed in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40457 - it 'has an N+1 due to an additional groups (with no parent group) - but should not' do + it 'does not have an N+1 due to an additional groups (with no parent group)' do get_profile_notifications control = ActiveRecord::QueryRecorder.new do @@ -37,7 +36,7 @@ RSpec.describe 'view user notifications' do expect do get_profile_notifications - end.to exceed_query_limit(control) + end.not_to exceed_query_limit(control) end end end diff --git a/spec/services/merge_requests/create_pipeline_service_spec.rb b/spec/services/merge_requests/create_pipeline_service_spec.rb index db46bd37eea..4dd70627977 100644 --- a/spec/services/merge_requests/create_pipeline_service_spec.rb +++ b/spec/services/merge_requests/create_pipeline_service_spec.rb @@ -5,13 +5,14 @@ require 'spec_helper' RSpec.describe MergeRequests::CreatePipelineService do include ProjectForksHelper - let_it_be(:project) { create(:project, :repository) } + let_it_be(:project, reload: true) { create(:project, :repository) } let_it_be(:user) { create(:user) } let(:service) { described_class.new(project, actor, params) } let(:actor) { user } let(:params) { {} } before do + stub_feature_flags(ci_disallow_to_create_merge_request_pipelines_in_target_project: false) project.add_developer(user) end @@ -58,9 +59,27 @@ RSpec.describe MergeRequests::CreatePipelineService do expect(subject.project).to eq(project) end - context 'when ci_allow_to_create_merge_request_pipelines_in_target_project feature flag is disabled' do + context 'when source branch is protected' do + context 'when actor does not have permission to update the protected branch in target project' do + let!(:protected_branch) { create(:protected_branch, name: '*', project: project) } + + it 'creates a pipeline in the source project' do + expect(subject.project).to eq(source_project) + end + end + + context 'when actor has permission to update the protected branch in target project' do + let!(:protected_branch) { create(:protected_branch, :developers_can_merge, name: '*', project: project) } + + it 'creates a pipeline in the target project' do + expect(subject.project).to eq(project) + end + end + end + + context 'when ci_disallow_to_create_merge_request_pipelines_in_target_project feature flag is enabled' do before do - stub_feature_flags(ci_allow_to_create_merge_request_pipelines_in_target_project: false) + stub_feature_flags(ci_disallow_to_create_merge_request_pipelines_in_target_project: true) end it 'creates a pipeline in the source project' do diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb index bb62e594e7a..5ccefe3d129 100644 --- a/spec/services/merge_requests/create_service_spec.rb +++ b/spec/services/merge_requests/create_service_spec.rb @@ -212,6 +212,7 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do end before do + stub_feature_flags(ci_disallow_to_create_merge_request_pipelines_in_target_project: false) target_project.add_developer(assignee) target_project.add_maintainer(user) end diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb index 0696e8a247f..46031330750 100644 --- a/spec/services/merge_requests/refresh_service_spec.rb +++ b/spec/services/merge_requests/refresh_service_spec.rb @@ -225,6 +225,10 @@ RSpec.describe MergeRequests::RefreshService do context 'when service runs on forked project' do let(:project) { @fork_project } + before do + stub_feature_flags(ci_disallow_to_create_merge_request_pipelines_in_target_project: false) + end + it 'creates detached merge request pipeline for fork merge request', :sidekiq_inline do expect { subject } .to change { @fork_merge_request.pipelines_for_merge_request.count }.by(1) |