From 4fd77e112fac07c5b904668b7d5f500589f8d2d5 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Thu, 5 Nov 2020 00:09:16 +0000 Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master --- .../issue_show/components/header_actions.vue | 4 +- .../pages/projects/ci/lints/new/index.js | 4 +- .../pages/projects/ci/lints/show/index.js | 4 +- .../pages/projects/commit/show/index.js | 2 +- .../tokens/milestone_token.vue | 2 +- .../projects/runners_controller.rb | 2 +- app/graphql/types/group_invitation_type.rb | 17 ++ app/graphql/types/invitation_interface.rb | 41 ++++ app/graphql/types/project_invitation_type.rb | 21 ++ app/helpers/issues_helper.rb | 2 +- app/models/concerns/from_union.rb | 21 -- app/models/member.rb | 2 + app/models/namespace.rb | 2 - app/models/project.rb | 1 - app/presenters/invitation_presenter.rb | 5 + app/services/members/invite_service.rb | 103 +++++++++ app/services/search/global_service.rb | 1 + app/services/search/group_service.rb | 1 + app/services/search/project_service.rb | 1 + .../251211-add-sorting-to-search-api.yml | 5 + ...63552-number-of-projects-active-alerts.yml | 5 + ...-remove-sql_set_operators-feature-flag.yml | 5 + .../add-post-invitations-with-email.yml | 5 + .../fj-forbid-top-level-route-sitemap.yml | 5 + .../disable_shared_runners_on_group.yml | 7 - .../development/sql_set_operators.yml | 7 - config/initializers/grape_validators.rb | 1 + ...4444_create_vulnerability_finding_links.rb | 25 +++ ...20201102112206_rename_sitemap_namespace.rb | 24 ++ db/schema_migrations/20201029144444 | 1 + db/schema_migrations/20201102112206 | 1 + db/structure.sql | 30 +++ doc/api/api_resources.md | 2 + doc/api/invitations.md | 66 ++++++ doc/api/search.md | 6 + doc/development/api_styleguide.md | 6 + doc/development/architecture.md | 10 + doc/development/contributing/style_guides.md | 3 +- doc/user/application_security/dast/index.md | 6 + doc/user/reserved_names.md | 1 + lib/api/api.rb | 1 + lib/api/entities/invitation.rb | 14 ++ lib/api/invitations.rb | 35 +++ lib/api/search.rb | 4 +- .../validators/email_or_email_list.rb | 21 ++ lib/gitlab/group_search_results.rb | 4 +- lib/gitlab/path_regex.rb | 1 + lib/gitlab/project_search_results.rb | 4 +- lib/gitlab/search/sort_options.rb | 21 ++ lib/gitlab/search_results.rb | 13 +- lib/gitlab/usage_data.rb | 4 +- locale/gitlab.pot | 6 + .../tokens/milestone_token_spec.js | 4 +- .../types/group_invitation_type_spec.rb | 19 ++ .../types/invitation_interface_spec.rb | 43 ++++ .../types/project_invitation_type_spec.rb | 19 ++ .../validators/email_or_email_list_spec.rb | 28 +++ spec/lib/gitlab/path_regex_spec.rb | 4 +- spec/lib/gitlab/search/sort_options_spec.rb | 34 +++ spec/lib/gitlab/usage_data_spec.rb | 4 + .../rename_sitemap_namespace_spec.rb | 30 +++ spec/models/concerns/from_union_spec.rb | 10 +- spec/requests/api/invitations_spec.rb | 207 ++++++++++++++++++ spec/requests/api/search_spec.rb | 54 +++++ spec/services/members/invite_service_spec.rb | 66 ++++++ 65 files changed, 1035 insertions(+), 72 deletions(-) create mode 100644 app/graphql/types/group_invitation_type.rb create mode 100644 app/graphql/types/invitation_interface.rb create mode 100644 app/graphql/types/project_invitation_type.rb create mode 100644 app/presenters/invitation_presenter.rb create mode 100644 app/services/members/invite_service.rb create mode 100644 changelogs/unreleased/251211-add-sorting-to-search-api.yml create mode 100644 changelogs/unreleased/263552-number-of-projects-active-alerts.yml create mode 100644 changelogs/unreleased/273356-remove-sql_set_operators-feature-flag.yml create mode 100644 changelogs/unreleased/add-post-invitations-with-email.yml create mode 100644 changelogs/unreleased/fj-forbid-top-level-route-sitemap.yml delete mode 100644 config/feature_flags/development/disable_shared_runners_on_group.yml delete mode 100644 config/feature_flags/development/sql_set_operators.yml create mode 100644 db/migrate/20201029144444_create_vulnerability_finding_links.rb create mode 100644 db/post_migrate/20201102112206_rename_sitemap_namespace.rb create mode 100644 db/schema_migrations/20201029144444 create mode 100644 db/schema_migrations/20201102112206 create mode 100644 doc/api/invitations.md create mode 100644 lib/api/entities/invitation.rb create mode 100644 lib/api/invitations.rb create mode 100644 lib/api/validations/validators/email_or_email_list.rb create mode 100644 lib/gitlab/search/sort_options.rb create mode 100644 spec/graphql/types/group_invitation_type_spec.rb create mode 100644 spec/graphql/types/invitation_interface_spec.rb create mode 100644 spec/graphql/types/project_invitation_type_spec.rb create mode 100644 spec/lib/api/validations/validators/email_or_email_list_spec.rb create mode 100644 spec/lib/gitlab/search/sort_options_spec.rb create mode 100644 spec/migrations/rename_sitemap_namespace_spec.rb create mode 100644 spec/requests/api/invitations_spec.rb create mode 100644 spec/services/members/invite_service_spec.rb diff --git a/app/assets/javascripts/issue_show/components/header_actions.vue b/app/assets/javascripts/issue_show/components/header_actions.vue index 76fc7d0cb47..e59c8fd837c 100644 --- a/app/assets/javascripts/issue_show/components/header_actions.vue +++ b/app/assets/javascripts/issue_show/components/header_actions.vue @@ -81,7 +81,7 @@ export default { }) .then(({ data }) => { if (data.updateIssue.errors.length) { - createFlash(data.updateIssue.errors.join('. ')); + createFlash({ message: data.updateIssue.errors.join('. ') }); return; } @@ -95,7 +95,7 @@ export default { // Dispatch event which updates open/close state, shared among the issue show page document.dispatchEvent(new CustomEvent('issuable_vue_app:change', payload)); }) - .catch(() => createFlash(__('Update failed. Please try again.'))) + .catch(() => createFlash({ message: __('Update failed. Please try again.') })) .finally(() => { this.isUpdatingState = false; }); diff --git a/app/assets/javascripts/pages/projects/ci/lints/new/index.js b/app/assets/javascripts/pages/projects/ci/lints/new/index.js index 957801320c9..5a0689b15f2 100644 --- a/app/assets/javascripts/pages/projects/ci/lints/new/index.js +++ b/app/assets/javascripts/pages/projects/ci/lints/new/index.js @@ -7,11 +7,11 @@ document.addEventListener('DOMContentLoaded', () => { if (gon?.features?.ciLintVue) { import(/* webpackChunkName: 'ciLintIndex' */ '~/ci_lint/index') .then(module => module.default()) - .catch(() => createFlash(ERROR)); + .catch(() => createFlash({ message: ERROR })); } else { import(/* webpackChunkName: 'ciLintEditor' */ '../ci_lint_editor') // eslint-disable-next-line new-cap .then(module => new module.default()) - .catch(() => createFlash(ERROR)); + .catch(() => createFlash({ message: ERROR })); } }); diff --git a/app/assets/javascripts/pages/projects/ci/lints/show/index.js b/app/assets/javascripts/pages/projects/ci/lints/show/index.js index 957801320c9..5a0689b15f2 100644 --- a/app/assets/javascripts/pages/projects/ci/lints/show/index.js +++ b/app/assets/javascripts/pages/projects/ci/lints/show/index.js @@ -7,11 +7,11 @@ document.addEventListener('DOMContentLoaded', () => { if (gon?.features?.ciLintVue) { import(/* webpackChunkName: 'ciLintIndex' */ '~/ci_lint/index') .then(module => module.default()) - .catch(() => createFlash(ERROR)); + .catch(() => createFlash({ message: ERROR })); } else { import(/* webpackChunkName: 'ciLintEditor' */ '../ci_lint_editor') // eslint-disable-next-line new-cap .then(module => new module.default()) - .catch(() => createFlash(ERROR)); + .catch(() => createFlash({ message: ERROR })); } }); diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js index 32fb35f97e3..e0bd49bf6ef 100644 --- a/app/assets/javascripts/pages/projects/commit/show/index.js +++ b/app/assets/javascripts/pages/projects/commit/show/index.js @@ -40,7 +40,7 @@ document.addEventListener('DOMContentLoaded', () => { new Diff(); }) .catch(() => { - flash(__('An error occurred while retrieving diff files')); + flash({ message: __('An error occurred while retrieving diff files') }); }); } else { new Diff(); diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue index 89952623d0d..c24df5e081d 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue @@ -65,7 +65,7 @@ export default { .then(({ data }) => { this.milestones = data; }) - .catch(() => createFlash(__('There was a problem fetching milestones.'))) + .catch(() => createFlash({ message: __('There was a problem fetching milestones.') })) .finally(() => { this.loading = false; }); diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb index 544074f9840..24fa0894a9c 100644 --- a/app/controllers/projects/runners_controller.rb +++ b/app/controllers/projects/runners_controller.rb @@ -52,7 +52,7 @@ class Projects::RunnersController < Projects::ApplicationController end def toggle_shared_runners - if Feature.enabled?(:disable_shared_runners_on_group, default_enabled: true) && !project.shared_runners_enabled && project.group && project.group.shared_runners_setting == 'disabled_and_unoverridable' + if !project.shared_runners_enabled && project.group && project.group.shared_runners_setting == 'disabled_and_unoverridable' return redirect_to project_runners_path(@project), alert: _("Cannot enable shared runners because parent group does not allow it") end diff --git a/app/graphql/types/group_invitation_type.rb b/app/graphql/types/group_invitation_type.rb new file mode 100644 index 00000000000..0372ce178ff --- /dev/null +++ b/app/graphql/types/group_invitation_type.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + class GroupInvitationType < BaseObject + expose_permissions Types::PermissionTypes::Group + authorize :read_group + + implements InvitationInterface + + graphql_name 'GroupInvitation' + description 'Represents a Group Invitation' + + field :group, Types::GroupType, null: true, + description: 'Group that a User is invited to', + resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, obj.source_id).find } + end +end diff --git a/app/graphql/types/invitation_interface.rb b/app/graphql/types/invitation_interface.rb new file mode 100644 index 00000000000..a29716c292e --- /dev/null +++ b/app/graphql/types/invitation_interface.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Types + module InvitationInterface + include BaseInterface + + field :email, GraphQL::STRING_TYPE, null: false, + description: 'Email of the member to invite' + + field :access_level, Types::AccessLevelType, null: true, + description: 'GitLab::Access level' + + field :created_by, Types::UserType, null: true, + description: 'User that authorized membership' + + field :created_at, Types::TimeType, null: true, + description: 'Date and time the membership was created' + + field :updated_at, Types::TimeType, null: true, + description: 'Date and time the membership was last updated' + + field :expires_at, Types::TimeType, null: true, + description: 'Date and time the membership expires' + + field :user, Types::UserType, null: true, + description: 'User that is associated with the member object' + + definition_methods do + def resolve_type(object, context) + case object + when GroupMember + Types::GroupInvitationType + when ProjectMember + Types::ProjectInvitationType + else + raise ::Gitlab::Graphql::Errors::BaseError, "Unknown member type #{object.class.name}" + end + end + end + end +end diff --git a/app/graphql/types/project_invitation_type.rb b/app/graphql/types/project_invitation_type.rb new file mode 100644 index 00000000000..a5367a4f204 --- /dev/null +++ b/app/graphql/types/project_invitation_type.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + class ProjectInvitationType < BaseObject + graphql_name 'ProjectInvitation' + description 'Represents a Project Membership Invitation' + + expose_permissions Types::PermissionTypes::Project + + implements InvitationInterface + + authorize :read_project + + field :project, Types::ProjectType, null: true, + description: 'Project ID for the project of the invitation' + + def project + Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.source_id).find + end + end +end diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index e0478be71cb..5447d256644 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -160,7 +160,7 @@ module IssuesHelper can_report_spam: issue.submittable_as_spam_by?(current_user).to_s, can_update_issue: can?(current_user, :update_issue, issue).to_s, iid: issue.iid, - is_issue_author: issue.author == current_user, + is_issue_author: (issue.author == current_user).to_s, new_issue_path: new_project_issue_path(project), project_path: project.full_path, report_abuse_path: new_abuse_report_path(user_id: issue.author.id, ref_url: issue_url(issue)), diff --git a/app/models/concerns/from_union.rb b/app/models/concerns/from_union.rb index e25d603b802..be6744f1b2a 100644 --- a/app/models/concerns/from_union.rb +++ b/app/models/concerns/from_union.rb @@ -37,27 +37,6 @@ module FromUnion # rubocop: disable Gitlab/Union extend FromSetOperator define_set_operator Gitlab::SQL::Union - - alias_method :from_union_set_operator, :from_union - def from_union(members, remove_duplicates: true, alias_as: table_name) - if Feature.enabled?(:sql_set_operators) - from_union_set_operator(members, remove_duplicates: remove_duplicates, alias_as: alias_as) - else - # The original from_union method. - standard_from_union(members, remove_duplicates: remove_duplicates, alias_as: alias_as) - end - end - - private - - def standard_from_union(members, remove_duplicates: true, alias_as: table_name) - union = Gitlab::SQL::Union - .new(members, remove_duplicates: remove_duplicates) - .to_sql - - from(Arel.sql("(#{union}) #{alias_as}")) - end - # rubocop: enable Gitlab/Union end end diff --git a/app/models/member.rb b/app/models/member.rb index 498e03b2c1a..07a3bc1dc1d 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -96,6 +96,8 @@ class Member < ApplicationRecord scope :owners, -> { active.where(access_level: OWNER) } scope :owners_and_maintainers, -> { active.where(access_level: [OWNER, MAINTAINER]) } scope :with_user, -> (user) { where(user: user) } + scope :with_user_by_email, -> (email) { left_join_users.where(users: { email: email } ) } + scope :preload_user_and_notification_settings, -> { preload(user: :notification_settings) } scope :with_source_id, ->(source_id) { where(source_id: source_id) } diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 5c60fc42ff0..016cf4b37a9 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -393,7 +393,6 @@ class Namespace < ApplicationRecord end def changing_shared_runners_enabled_is_allowed - return unless Feature.enabled?(:disable_shared_runners_on_group, default_enabled: true) return unless new_record? || changes.has_key?(:shared_runners_enabled) if shared_runners_enabled && has_parent? && parent.shared_runners_setting == 'disabled_and_unoverridable' @@ -402,7 +401,6 @@ class Namespace < ApplicationRecord end def changing_allow_descendants_override_disabled_shared_runners_is_allowed - return unless Feature.enabled?(:disable_shared_runners_on_group, default_enabled: true) return unless new_record? || changes.has_key?(:allow_descendants_override_disabled_shared_runners) if shared_runners_enabled && !new_record? diff --git a/app/models/project.rb b/app/models/project.rb index d002d7edeb5..2e47430d009 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1195,7 +1195,6 @@ class Project < ApplicationRecord end def changing_shared_runners_enabled_is_allowed - return unless Feature.enabled?(:disable_shared_runners_on_group, default_enabled: true) return unless new_record? || changes.has_key?(:shared_runners_enabled) if shared_runners_enabled && group && group.shared_runners_setting == 'disabled_and_unoverridable' diff --git a/app/presenters/invitation_presenter.rb b/app/presenters/invitation_presenter.rb new file mode 100644 index 00000000000..d8c07f327dd --- /dev/null +++ b/app/presenters/invitation_presenter.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class InvitationPresenter < Gitlab::View::Presenter::Delegated + presents :invitation +end diff --git a/app/services/members/invite_service.rb b/app/services/members/invite_service.rb new file mode 100644 index 00000000000..cfab5c3ef9d --- /dev/null +++ b/app/services/members/invite_service.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module Members + class InviteService < Members::BaseService + DEFAULT_LIMIT = 100 + + attr_reader :errors + + def initialize(current_user, params) + @current_user, @params = current_user, params.dup + @errors = {} + end + + def execute(source) + return error(s_('Email cannot be blank')) if params[:email].blank? + + emails = params[:email].split(',').uniq.flatten + return error(s_("Too many users specified (limit is %{user_limit})") % { user_limit: user_limit }) if + user_limit && emails.size > user_limit + + emails.each do |email| + next if existing_member?(source, email) + + next if existing_invite?(source, email) + + if existing_user?(email) + add_existing_user_as_member(current_user, source, params, email) + next + end + + invite_new_member_and_user(current_user, source, params, email) + end + + return success unless errors.any? + + error(errors) + end + + private + + def invite_new_member_and_user(current_user, source, params, email) + new_member = (source.class.name + 'Member').constantize.create(source_id: source.id, + user_id: nil, + access_level: params[:access_level], + invite_email: email, + created_by_id: current_user.id, + expires_at: params[:expires_at], + requested_at: Time.current.utc) + + unless new_member.valid? && new_member.persisted? + errors[params[:email]] = new_member.errors.full_messages.to_sentence + end + end + + def add_existing_user_as_member(current_user, source, params, email) + new_member = create_member(current_user, existing_user(email), source, params.merge({ invite_email: email })) + + unless new_member.valid? && new_member.persisted? + errors[email] = new_member.errors.full_messages.to_sentence + end + end + + def create_member(current_user, user, source, params) + source.add_user(user, params[:access_level], current_user: current_user, expires_at: params[:expires_at]) + end + + def user_limit + limit = params.fetch(:limit, DEFAULT_LIMIT) + + limit && limit < 0 ? nil : limit + end + + def existing_member?(source, email) + existing_member = source.members.with_user_by_email(email).exists? + + if existing_member + errors[email] = "Already a member of #{source.name}" + return true + end + + false + end + + def existing_invite?(source, email) + existing_invite = source.members.search_invite_email(email).exists? + + if existing_invite + errors[email] = "Member already invited to #{source.name}" + return true + end + + false + end + + def existing_user(email) + User.find_by_email(email) + end + + def existing_user?(email) + existing_user(email).present? + end + end +end diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb index 5f80b07aa59..9038650adb7 100644 --- a/app/services/search/global_service.rb +++ b/app/services/search/global_service.rb @@ -16,6 +16,7 @@ module Search Gitlab::SearchResults.new(current_user, params[:search], projects, + order_by: params[:order_by], sort: params[:sort], filters: { state: params[:state], confidential: params[:confidential] }) end diff --git a/app/services/search/group_service.rb b/app/services/search/group_service.rb index e17522dcd68..4b2d8499582 100644 --- a/app/services/search/group_service.rb +++ b/app/services/search/group_service.rb @@ -16,6 +16,7 @@ module Search params[:search], projects, group: group, + order_by: params[:order_by], sort: params[:sort], filters: { state: params[:state], confidential: params[:confidential] } ) diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb index d32534303be..e5fc5a7a438 100644 --- a/app/services/search/project_service.rb +++ b/app/services/search/project_service.rb @@ -17,6 +17,7 @@ module Search params[:search], project: project, repository_ref: params[:repository_ref], + order_by: params[:order_by], sort: params[:sort], filters: { confidential: params[:confidential], state: params[:state] } ) diff --git a/changelogs/unreleased/251211-add-sorting-to-search-api.yml b/changelogs/unreleased/251211-add-sorting-to-search-api.yml new file mode 100644 index 00000000000..3f0764bda69 --- /dev/null +++ b/changelogs/unreleased/251211-add-sorting-to-search-api.yml @@ -0,0 +1,5 @@ +--- +title: Add ability to sort to search API +merge_request: 46646 +author: +type: added diff --git a/changelogs/unreleased/263552-number-of-projects-active-alerts.yml b/changelogs/unreleased/263552-number-of-projects-active-alerts.yml new file mode 100644 index 00000000000..6d9ffd9947a --- /dev/null +++ b/changelogs/unreleased/263552-number-of-projects-active-alerts.yml @@ -0,0 +1,5 @@ +--- +title: Add metric count for projects with alerts created +merge_request: 46636 +author: +type: added diff --git a/changelogs/unreleased/273356-remove-sql_set_operators-feature-flag.yml b/changelogs/unreleased/273356-remove-sql_set_operators-feature-flag.yml new file mode 100644 index 00000000000..9df49692ee5 --- /dev/null +++ b/changelogs/unreleased/273356-remove-sql_set_operators-feature-flag.yml @@ -0,0 +1,5 @@ +--- +title: Enable refactored union set operator +merge_request: 46295 +author: +type: added diff --git a/changelogs/unreleased/add-post-invitations-with-email.yml b/changelogs/unreleased/add-post-invitations-with-email.yml new file mode 100644 index 00000000000..be96a0da598 --- /dev/null +++ b/changelogs/unreleased/add-post-invitations-with-email.yml @@ -0,0 +1,5 @@ +--- +title: Add API post /invitations by email +merge_request: 45950 +author: +type: added diff --git a/changelogs/unreleased/fj-forbid-top-level-route-sitemap.yml b/changelogs/unreleased/fj-forbid-top-level-route-sitemap.yml new file mode 100644 index 00000000000..8baad160fb4 --- /dev/null +++ b/changelogs/unreleased/fj-forbid-top-level-route-sitemap.yml @@ -0,0 +1,5 @@ +--- +title: Forbid top level route sitemap +merge_request: 46677 +author: +type: changed diff --git a/config/feature_flags/development/disable_shared_runners_on_group.yml b/config/feature_flags/development/disable_shared_runners_on_group.yml deleted file mode 100644 index 86ccf59c8a0..00000000000 --- a/config/feature_flags/development/disable_shared_runners_on_group.yml +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: disable_shared_runners_on_group -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36080 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/258991 -type: development -group: group::runner -default_enabled: true diff --git a/config/feature_flags/development/sql_set_operators.yml b/config/feature_flags/development/sql_set_operators.yml deleted file mode 100644 index 2098a19a24a..00000000000 --- a/config/feature_flags/development/sql_set_operators.yml +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: sql_set_operators -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39786 -rollout_issue_url: -group: group::access -type: development -default_enabled: false diff --git a/config/initializers/grape_validators.rb b/config/initializers/grape_validators.rb index 22f2c9ecf92..715949a276f 100644 --- a/config/initializers/grape_validators.rb +++ b/config/initializers/grape_validators.rb @@ -8,3 +8,4 @@ Grape::Validations.register_validator(:integer_none_any, ::API::Validations::Val Grape::Validations.register_validator(:array_none_any, ::API::Validations::Validators::ArrayNoneAny) Grape::Validations.register_validator(:check_assignees_count, ::API::Validations::Validators::CheckAssigneesCount) Grape::Validations.register_validator(:untrusted_regexp, ::API::Validations::Validators::UntrustedRegexp) +Grape::Validations.register_validator(:email_or_email_list, ::API::Validations::Validators::EmailOrEmailList) diff --git a/db/migrate/20201029144444_create_vulnerability_finding_links.rb b/db/migrate/20201029144444_create_vulnerability_finding_links.rb new file mode 100644 index 00000000000..80f93b9a0af --- /dev/null +++ b/db/migrate/20201029144444_create_vulnerability_finding_links.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class CreateVulnerabilityFindingLinks < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + create_table :vulnerability_finding_links, if_not_exists: true do |t| + t.timestamps_with_timezone null: false + t.references :vulnerability_occurrence, index: { name: 'finding_links_on_vulnerability_occurrence_id' }, null: false, foreign_key: { on_delete: :cascade } + t.text :name, limit: 255 + t.text :url, limit: 2048, null: false + end + + add_text_limit :vulnerability_finding_links, :name, 255 + add_text_limit :vulnerability_finding_links, :url, 2048 + end + + def down + drop_table :vulnerability_finding_links + end +end diff --git a/db/post_migrate/20201102112206_rename_sitemap_namespace.rb b/db/post_migrate/20201102112206_rename_sitemap_namespace.rb new file mode 100644 index 00000000000..b2e610d68db --- /dev/null +++ b/db/post_migrate/20201102112206_rename_sitemap_namespace.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class RenameSitemapNamespace < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + include Gitlab::Database::RenameReservedPathsMigration::V1 + + DOWNTIME = false + + disable_ddl_transaction! + + # We're taking over the /sitemap namespace + # since it's necessary for the default behavior of Sitemaps + def up + disable_statement_timeout do + rename_root_paths(['sitemap']) + end + end + + def down + disable_statement_timeout do + revert_renames + end + end +end diff --git a/db/schema_migrations/20201029144444 b/db/schema_migrations/20201029144444 new file mode 100644 index 00000000000..1ab20b33da5 --- /dev/null +++ b/db/schema_migrations/20201029144444 @@ -0,0 +1 @@ +50e4e42c804d3abdcfe9ab2bbb890262d4b2ddd93bff1b2af1da1e55a0300cf5 \ No newline at end of file diff --git a/db/schema_migrations/20201102112206 b/db/schema_migrations/20201102112206 new file mode 100644 index 00000000000..5bbba26e110 --- /dev/null +++ b/db/schema_migrations/20201102112206 @@ -0,0 +1 @@ +a861c91ebc7f7892020ba10a151df761b38bf69d5e02bcdf72a965eb266e6aff \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index d955a3e6dcd..8c278442790 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -17104,6 +17104,26 @@ CREATE SEQUENCE vulnerability_feedback_id_seq ALTER SEQUENCE vulnerability_feedback_id_seq OWNED BY vulnerability_feedback.id; +CREATE TABLE vulnerability_finding_links ( + id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + vulnerability_occurrence_id bigint NOT NULL, + name text, + url text NOT NULL, + CONSTRAINT check_55f0a95439 CHECK ((char_length(name) <= 255)), + CONSTRAINT check_b7fe886df6 CHECK ((char_length(url) <= 2048)) +); + +CREATE SEQUENCE vulnerability_finding_links_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE vulnerability_finding_links_id_seq OWNED BY vulnerability_finding_links.id; + CREATE TABLE vulnerability_historical_statistics ( id bigint NOT NULL, created_at timestamp with time zone NOT NULL, @@ -18203,6 +18223,8 @@ ALTER TABLE ONLY vulnerability_exports ALTER COLUMN id SET DEFAULT nextval('vuln ALTER TABLE ONLY vulnerability_feedback ALTER COLUMN id SET DEFAULT nextval('vulnerability_feedback_id_seq'::regclass); +ALTER TABLE ONLY vulnerability_finding_links ALTER COLUMN id SET DEFAULT nextval('vulnerability_finding_links_id_seq'::regclass); + ALTER TABLE ONLY vulnerability_historical_statistics ALTER COLUMN id SET DEFAULT nextval('vulnerability_historical_statistics_id_seq'::regclass); ALTER TABLE ONLY vulnerability_identifiers ALTER COLUMN id SET DEFAULT nextval('vulnerability_identifiers_id_seq'::regclass); @@ -19646,6 +19668,9 @@ ALTER TABLE ONLY vulnerability_exports ALTER TABLE ONLY vulnerability_feedback ADD CONSTRAINT vulnerability_feedback_pkey PRIMARY KEY (id); +ALTER TABLE ONLY vulnerability_finding_links + ADD CONSTRAINT vulnerability_finding_links_pkey PRIMARY KEY (id); + ALTER TABLE ONLY vulnerability_historical_statistics ADD CONSTRAINT vulnerability_historical_statistics_pkey PRIMARY KEY (id); @@ -19870,6 +19895,8 @@ CREATE UNIQUE INDEX epic_user_mentions_on_epic_id_and_note_id_index ON epic_user CREATE UNIQUE INDEX epic_user_mentions_on_epic_id_index ON epic_user_mentions USING btree (epic_id) WHERE (note_id IS NULL); +CREATE INDEX finding_links_on_vulnerability_occurrence_id ON vulnerability_finding_links USING btree (vulnerability_occurrence_id); + CREATE INDEX idx_audit_events_on_entity_id_desc_author_id_created_at ON audit_events USING btree (entity_id, entity_type, id DESC, author_id, created_at); CREATE INDEX idx_ci_pipelines_artifacts_locked ON ci_pipelines USING btree (ci_ref_id, id) WHERE (locked = 1); @@ -24195,6 +24222,9 @@ ALTER TABLE ONLY gpg_signatures ALTER TABLE ONLY board_group_recent_visits ADD CONSTRAINT fk_rails_ca04c38720 FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE; +ALTER TABLE ONLY vulnerability_finding_links + ADD CONSTRAINT fk_rails_cbdfde27ce FOREIGN KEY (vulnerability_occurrence_id) REFERENCES vulnerability_occurrences(id) ON DELETE CASCADE; + ALTER TABLE ONLY issues_self_managed_prometheus_alert_events ADD CONSTRAINT fk_rails_cc5d88bbb0 FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE; diff --git a/doc/api/api_resources.md b/doc/api/api_resources.md index 436a0eb93f1..7ef3b5fcbb6 100644 --- a/doc/api/api_resources.md +++ b/doc/api/api_resources.md @@ -40,6 +40,7 @@ The following API resources are available in the project context: | [Events](events.md) | `/projects/:id/events` (also available for users and standalone) | | [Feature Flags](feature_flags.md) | `/projects/:id/feature_flags` | | [Feature Flag User Lists](feature_flag_user_lists.md) | `/projects/:id/feature_flags_user_lists` | +| [Invitations](invitations.md) | `/projects/:id/invitations` (also available for groups) | | [Issues](issues.md) | `/projects/:id/issues` (also available for groups and standalone) | | [Issues Statistics](issues_statistics.md) | `/projects/:id/issues_statistics` (also available for groups and standalone) | | [Issue boards](boards.md) | `/projects/:id/boards` | @@ -108,6 +109,7 @@ The following API resources are available in the group context: | [Group labels](group_labels.md) | `/groups/:id/labels` | | [Group-level variables](group_level_variables.md) | `/groups/:id/variables` | | [Group milestones](group_milestones.md) | `/groups/:id/milestones` | +| [Invitations](invitations.md) | `/groups/:id/invitations` (also available for projects) | | [Issues](issues.md) | `/groups/:id/issues` (also available for projects and standalone) | | [Issues Statistics](issues_statistics.md) | `/groups/:id/issues_statistics` (also available for projects and standalone) | | [Members](members.md) | `/groups/:id/members` (also available for projects) | diff --git a/doc/api/invitations.md b/doc/api/invitations.md new file mode 100644 index 00000000000..ecdc58f3e03 --- /dev/null +++ b/doc/api/invitations.md @@ -0,0 +1,66 @@ +--- +stage: Growth +group: Expansion +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + +# Invitations API + +Use the Invitations API to send email to users you want to join a group or project. + +## Valid access levels + +To send an invitation, you must have access to the project or group you are sending email for. Valid access +levels are defined in the `Gitlab::Access` module. Currently, these levels are valid: + +- No access (`0`) +- Guest (`10`) +- Reporter (`20`) +- Developer (`30`) +- Maintainer (`40`) +- Owner (`50`) - Only valid to set for groups + +CAUTION: **Caution:** +Due to [an issue](https://gitlab.com/gitlab-org/gitlab/-/issues/219299), +projects in personal namespaces will not show owner (`50`) permission. + +## Invite by email to group or project + +Invites a new user by email to join a group or project. + +```plaintext +POST /groups/:id/invitations +POST /projects/:id/invitations +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project or group](README.md#namespaced-path-encoding) owned by the authenticated user | +| `email` | integer/string | yes | The email of the new member or multiple emails separated by commas | +| `access_level` | integer | yes | A valid access level | +| `expires_at` | string | no | A date string in the format YEAR-MONTH-DAY | + +```shell +curl --request POST --header "PRIVATE-TOKEN: " --data "email=test@example.com&access_level=30" "https://gitlab.example.com/api/v4/groups/:id/invitations" +curl --request POST --header "PRIVATE-TOKEN: " --data "email=test@example.com&access_level=30" "https://gitlab.example.com/api/v4/projects/:id/invitations" +``` + +Example responses: + +When all emails were successfully sent: + +```json +{ "status": "success" } +``` + +When there was any error sending the email: + +```json +{ + "status": "error", + "message": { + "test@example.com": "Already invited", + "test2@example.com": "Member already exsists" + } +} +``` diff --git a/doc/api/search.md b/doc/api/search.md index 9eb9f93e5d0..8de93ce0d32 100644 --- a/doc/api/search.md +++ b/doc/api/search.md @@ -26,6 +26,8 @@ GET /search | `search` | string | yes | The search query | | `state` | string | no | Filter by state. Issues and merge requests are supported; it is ignored for other scopes. | | `confidential` | boolean | no | Filter by confidentiality. Issues scope is supported; it is ignored for other scopes. | +| `order_by` | string | no | Allowed values are `created_at` only. If this is not set, the results will either be sorted by `created_at` in descending order for basic search, or by the most relevant documents when using advanced search.| +| `sort` | string | no | Allowed values are `asc` or `desc` only. If this is not set, the results will either be sorted by `created_at` in descending order for basic search, or by the most relevant documents when using advanced search.| Search the expression within the specified scope. Currently these scopes are supported: projects, issues, merge_requests, milestones, snippet_titles, users. @@ -436,6 +438,8 @@ GET /groups/:id/search | `search` | string | yes | The search query | | `state` | string | no | Filter by state. Issues and merge requests are supported; it is ignored for other scopes. | | `confidential` | boolean | no | Filter by confidentiality. Issues scope is supported; it is ignored for other scopes. | +| `order_by` | string | no | Allowed values are `created_at` only. If this is not set, the results will either be sorted by `created_at` in descending order for basic search, or by the most relevant documents when using advanced search.| +| `sort` | string | no | Allowed values are `asc` or `desc` only. If this is not set, the results will either be sorted by `created_at` in descending order for basic search, or by the most relevant documents when using advanced search.| Search the expression within the specified scope. Currently these scopes are supported: projects, issues, merge_requests, milestones, users. @@ -816,6 +820,8 @@ GET /projects/:id/search | `ref` | string | no | The name of a repository branch or tag to search on. The project's default branch is used by default. This is only applicable for scopes: commits, blobs, and wiki_blobs. | | `state` | string | no | Filter by state. Issues and merge requests are supported; it is ignored for other scopes. | | `confidential` | boolean | no | Filter by confidentiality. Issues scope is supported; it is ignored for other scopes. | +| `order_by` | string | no | Allowed values are `created_at` only. If this is not set, the results will either be sorted by `created_at` in descending order for basic search, or by the most relevant documents when using advanced search.| +| `sort` | string | no | Allowed values are `asc` or `desc` only. If this is not set, the results will either be sorted by `created_at` in descending order for basic search, or by the most relevant documents when using advanced search.| Search the expression within the specified scope. Currently these scopes are supported: issues, merge_requests, milestones, notes, wiki_blobs, commits, blobs, users. diff --git a/doc/development/api_styleguide.md b/doc/development/api_styleguide.md index fcdda7f8138..dd652ccd587 100644 --- a/doc/development/api_styleguide.md +++ b/doc/development/api_styleguide.md @@ -207,6 +207,12 @@ guide on how you can add a new custom validator. checks if the value of the given parameter is either an `Array`, `None`, or `Any`. It allows only either of these mentioned values to move forward in the request. +- `EmailOrEmailList`: + + The [`EmailOrEmailList` validator](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/api/validations/validators/email_or_email_list.rb) + checks if the value of a string or a list of strings contains only valid + email addresses. It allows only lists with all valid email addresses to move forward in the request. + ### Adding a new custom validator Custom validators are a great way to validate parameters before sending diff --git a/doc/development/architecture.md b/doc/development/architecture.md index f5597c5c5d8..886823c29c4 100644 --- a/doc/development/architecture.md +++ b/doc/development/architecture.md @@ -258,6 +258,7 @@ Table description links: | [NGINX](#nginx) | Routes requests to appropriate components, terminates SSL | ✅ | ✅ | ⚙ | ✅ | ⤓ | ❌ | CE & EE | | [Node Exporter](#node-exporter) | Prometheus endpoint with system metrics | ✅ | N/A | N/A | ✅ | ❌ | ❌ | CE & EE | | [Outbound email (SMTP)](#outbound-email) | Send email messages to users | ⤓ | ⚙ | ⤓ | ✅ | ⤓ | ⤓ | CE & EE | +| [Patroni](#patroni) | Manage PostgreSQL HA cluster leader selection and replication | ⚙ | ❌ | ❌ | ✅ | ❌ | ❌ | EE Only | | [PgBouncer Exporter](#pgbouncer-exporter) | Prometheus endpoint with PgBouncer metrics | ⚙ | ❌ | ❌ | ✅ | ❌ | ❌ | CE & EE | | [PgBouncer](#pgbouncer) | Database connection pooling, failover | ⚙ | ❌ | ❌ | ✅ | ❌ | ❌ | EE Only | | [PostgreSQL Exporter](#postgresql-exporter) | Prometheus endpoint with PostgreSQL metrics | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | CE & EE | @@ -545,6 +546,15 @@ NGINX has an Ingress port for all HTTP requests and routes them to the appropria [Node Exporter](https://github.com/prometheus/node_exporter) is a Prometheus tool that gives us metrics on the underlying machine (think CPU/Disk/Load). It's just a packaged version of the common open source offering from the Prometheus project. +#### Patroni + +- [Project Page](https://github.com/zalando/patroni) +- Configuration: + - [Omnibus](../administration/postgresql/replication_and_failover.md#patroni) +- Layer: Core Service (Data) +- Process: `patroni` +- GitLab.com: [Database Architecture](https://about.gitlab.com/handbook/engineering/infrastructure/production/architecture/#database-architecture) + #### PgBouncer - [Project page](https://github.com/pgbouncer/pgbouncer/blob/master/README.md) diff --git a/doc/development/contributing/style_guides.md b/doc/development/contributing/style_guides.md index 560f7666a13..c0041b78f2c 100644 --- a/doc/development/contributing/style_guides.md +++ b/doc/development/contributing/style_guides.md @@ -45,7 +45,8 @@ That is, `LEFTHOOK=0 git push`. You can also: - Define [local configuration](https://github.com/Arkweid/lefthook/blob/master/docs/full_guide.md#local-config). -- Skip [checks per tag on the fly](https://github.com/Arkweid/lefthook/blob/master/docs/full_guide.md#skip-some-tags-on-the-fly). +- Skip [checks per tag on the fly](https://github.com/Arkweid/lefthook/blob/master/docs/full_guide.md#skip-some-tags-on-the-fly), e.g. `LEFTHOOK_EXCLUDE=frontend git push origin`. +- Run [hooks manually](https://github.com/Arkweid/lefthook/blob/master/docs/full_guide.md#run-githook-group-directly), e.g. `lefthook run pre-push`. ## Ruby, Rails, RSpec diff --git a/doc/user/application_security/dast/index.md b/doc/user/application_security/dast/index.md index b84c0d1923a..4abf80be09a 100644 --- a/doc/user/application_security/dast/index.md +++ b/doc/user/application_security/dast/index.md @@ -161,6 +161,12 @@ headers whose values you want masked. For details on how to mask headers, see It's also possible to authenticate the user before performing the DAST checks. +**Important:** It is highly recommended that you configure the scanner to authenticate to the application, +or it will not be able to check most of the application for security risks, as most +of your application is likely not accessible without authentication. It is also recommended +that you periodically confirm the scanner's authentication is still working as this tends to break over +time due to authentication changes to the application. + Create masked variables to pass the credentials that DAST uses. To create masked variables for the username and password, see [Create a custom variable in the UI](../../../ci/variables/README.md#create-a-custom-variable-in-the-ui). Note that the key of the username variable must be `DAST_USERNAME` diff --git a/doc/user/reserved_names.md b/doc/user/reserved_names.md index ea5b018b5b2..ea3ca7a28d7 100644 --- a/doc/user/reserved_names.md +++ b/doc/user/reserved_names.md @@ -82,6 +82,7 @@ Currently the following names are reserved as top level groups: - `s` - `search` - `sent_notifications` +- `sitemap` - `sitemap.xml` - `sitemap.xml.gz` - `slash-command-logo.png` diff --git a/lib/api/api.rb b/lib/api/api.rb index 973ce8c63c1..6a459292045 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -186,6 +186,7 @@ module API mount ::API::ImportBitbucketServer mount ::API::ImportGithub mount ::API::IssueLinks + mount ::API::Invitations mount ::API::Issues mount ::API::JobArtifacts mount ::API::Jobs diff --git a/lib/api/entities/invitation.rb b/lib/api/entities/invitation.rb new file mode 100644 index 00000000000..6698326bd6c --- /dev/null +++ b/lib/api/entities/invitation.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module API + module Entities + class Invitation < Grape::Entity + expose :access_level + expose :requested_at + expose :expires_at + expose :invite_email + expose :invite_token + expose :user_id + end + end +end diff --git a/lib/api/invitations.rb b/lib/api/invitations.rb new file mode 100644 index 00000000000..4485278286b --- /dev/null +++ b/lib/api/invitations.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module API + class Invitations < ::API::Base + feature_category :users + + before { authenticate! } + + helpers ::API::Helpers::MembersHelpers + + %w[group project].each do |source_type| + params do + requires :id, type: String, desc: "The #{source_type} ID" + end + resource source_type.pluralize, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + desc 'Invite non-members by email address to a group or project.' do + detail 'This feature was introduced in GitLab 13.6' + success Entities::Invitation + end + params do + requires :email, types: [String, Array[String]], email_or_email_list: true, desc: 'The email address to invite, or multiple emails separated by comma' + requires :access_level, type: Integer, values: Gitlab::Access.all_values, desc: 'A valid access level (defaults: `30`, developer access level)' + optional :expires_at, type: DateTime, desc: 'Date string in the format YEAR-MONTH-DAY' + end + post ":id/invitations" do + source = find_source(source_type, params[:id]) + + authorize_admin_source!(source_type, source) + + ::Members::InviteService.new(current_user, params).execute(source) + end + end + end + end +end diff --git a/lib/api/search.rb b/lib/api/search.rb index d6a1115d018..f0ffe6ba443 100644 --- a/lib/api/search.rb +++ b/lib/api/search.rb @@ -39,7 +39,9 @@ module API snippets: snippets?, basic_search: params[:basic_search], page: params[:page], - per_page: params[:per_page] + per_page: params[:per_page], + order_by: params[:order_by], + sort: params[:sort] }.merge(additional_params) results = SearchService.new(current_user, search_params).search_objects(preload_method) diff --git a/lib/api/validations/validators/email_or_email_list.rb b/lib/api/validations/validators/email_or_email_list.rb new file mode 100644 index 00000000000..b7f2a0cd443 --- /dev/null +++ b/lib/api/validations/validators/email_or_email_list.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module API + module Validations + module Validators + class EmailOrEmailList < Grape::Validations::Base + def validate_param!(attr_name, params) + value = params[attr_name] + + return unless value + + return if value.split(',').map { |v| ValidateEmail.valid?(v) }.all? + + raise Grape::Exceptions::Validation, + params: [@scope.full_name(attr_name)], + message: "contains an invalid email address" + end + end + end + end +end diff --git a/lib/gitlab/group_search_results.rb b/lib/gitlab/group_search_results.rb index 5fec50eecd2..dd872caee0e 100644 --- a/lib/gitlab/group_search_results.rb +++ b/lib/gitlab/group_search_results.rb @@ -4,10 +4,10 @@ module Gitlab class GroupSearchResults < SearchResults attr_reader :group - def initialize(current_user, query, limit_projects = nil, group:, default_project_filter: false, sort: nil, filters: {}) + def initialize(current_user, query, limit_projects = nil, group:, default_project_filter: false, order_by: nil, sort: nil, filters: {}) @group = group - super(current_user, query, limit_projects, default_project_filter: default_project_filter, sort: sort, filters: filters) + super(current_user, query, limit_projects, default_project_filter: default_project_filter, order_by: order_by, sort: sort, filters: filters) end # rubocop:disable CodeReuse/ActiveRecord diff --git a/lib/gitlab/path_regex.rb b/lib/gitlab/path_regex.rb index 488049a1ae6..893a3eed689 100644 --- a/lib/gitlab/path_regex.rb +++ b/lib/gitlab/path_regex.rb @@ -49,6 +49,7 @@ module Gitlab s search sent_notifications + sitemap sitemap.xml sitemap.xml.gz slash-command-logo.png diff --git a/lib/gitlab/project_search_results.rb b/lib/gitlab/project_search_results.rb index fd08b560e18..6719dc8362b 100644 --- a/lib/gitlab/project_search_results.rb +++ b/lib/gitlab/project_search_results.rb @@ -4,11 +4,11 @@ module Gitlab class ProjectSearchResults < SearchResults attr_reader :project, :repository_ref - def initialize(current_user, query, project:, repository_ref: nil, sort: nil, filters: {}) + def initialize(current_user, query, project:, repository_ref: nil, order_by: nil, sort: nil, filters: {}) @project = project @repository_ref = repository_ref.presence - super(current_user, query, [project], sort: sort, filters: filters) + super(current_user, query, [project], order_by: order_by, sort: sort, filters: filters) end def objects(scope, page: nil, per_page: DEFAULT_PER_PAGE, preload_method: nil) diff --git a/lib/gitlab/search/sort_options.rb b/lib/gitlab/search/sort_options.rb new file mode 100644 index 00000000000..3395c34d171 --- /dev/null +++ b/lib/gitlab/search/sort_options.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Gitlab + module Search + module SortOptions + def sort_and_direction(order_by, sort) + # Due to different uses of sort param in web vs. API requests we prefer + # order_by when present + case [order_by, sort] + when %w[created_at asc], [nil, 'created_asc'] + :created_at_asc + when %w[created_at desc], [nil, 'created_desc'] + :created_at_desc + else + :unknown + end + end + module_function :sort_and_direction # rubocop: disable Style/AccessModifierDeclarations + end + end +end diff --git a/lib/gitlab/search_results.rb b/lib/gitlab/search_results.rb index 7aab1def252..0091ae1e8ce 100644 --- a/lib/gitlab/search_results.rb +++ b/lib/gitlab/search_results.rb @@ -7,7 +7,7 @@ module Gitlab DEFAULT_PAGE = 1 DEFAULT_PER_PAGE = 20 - attr_reader :current_user, :query, :sort, :filters + attr_reader :current_user, :query, :order_by, :sort, :filters # Limit search results by passed projects # It allows us to search only for projects user has access to @@ -19,11 +19,12 @@ module Gitlab # query attr_reader :default_project_filter - def initialize(current_user, query, limit_projects = nil, sort: nil, default_project_filter: false, filters: {}) + def initialize(current_user, query, limit_projects = nil, order_by: nil, sort: nil, default_project_filter: false, filters: {}) @current_user = current_user @query = query @limit_projects = limit_projects || Project.all @default_project_filter = default_project_filter + @order_by = order_by @sort = sort @filters = filters end @@ -128,10 +129,12 @@ module Gitlab # rubocop: disable CodeReuse/ActiveRecord def apply_sort(scope) - case sort - when 'created_asc' + # Due to different uses of sort param we prefer order_by when + # present + case ::Gitlab::Search::SortOptions.sort_and_direction(order_by, sort) + when :created_at_asc scope.reorder('created_at ASC') - when 'created_desc' + when :created_at_desc scope.reorder('created_at DESC') else scope.reorder('created_at DESC') diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb index 4ec92649766..2a06c05e7d0 100644 --- a/lib/gitlab/usage_data.rb +++ b/lib/gitlab/usage_data.rb @@ -160,6 +160,7 @@ module Gitlab projects_with_tracing_enabled: count(ProjectTracingSetting), projects_with_error_tracking_enabled: count(::ErrorTracking::ProjectErrorTrackingSetting.where(enabled: true)), projects_with_alerts_service_enabled: count(AlertsService.active), + projects_with_alerts_created: distinct_count(::AlertManagement::Alert, :project_id), projects_with_prometheus_alerts: distinct_count(PrometheusAlert, :project_id), projects_with_terraform_reports: distinct_count(::Ci::JobArtifact.terraform_reports, :project_id), projects_with_terraform_states: distinct_count(::Terraform::State, :project_id), @@ -215,7 +216,8 @@ module Gitlab # rubocop: enable UsageData/LargeTable: packages: count(::Packages::Package.where(last_28_days_time_period)), personal_snippets: count(PersonalSnippet.where(last_28_days_time_period)), - project_snippets: count(ProjectSnippet.where(last_28_days_time_period)) + project_snippets: count(ProjectSnippet.where(last_28_days_time_period)), + projects_with_alerts_created: distinct_count(::AlertManagement::Alert.where(last_28_days_time_period), :project_id) }.merge( snowplow_event_counts(last_28_days_time_period(column: :collector_tstamp)), aggregated_metrics_monthly diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 5d7c849543b..01886a4edcb 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -9866,6 +9866,9 @@ msgstr "" msgid "Email Notification" msgstr "" +msgid "Email cannot be blank" +msgstr "" + msgid "Email could not be sent" msgstr "" @@ -28202,6 +28205,9 @@ msgstr "" msgid "Too many projects enabled. You will need to manage them via the console or the API." msgstr "" +msgid "Too many users specified (limit is %{user_limit})" +msgstr "" + msgid "Too much data" msgstr "" diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js index 0ec814e3f15..5d91eafbabf 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js @@ -120,7 +120,9 @@ describe('MilestoneToken', () => { wrapper.vm.fetchMilestoneBySearchTerm('foo'); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith('There was a problem fetching milestones.'); + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem fetching milestones.', + }); }); }); diff --git a/spec/graphql/types/group_invitation_type_spec.rb b/spec/graphql/types/group_invitation_type_spec.rb new file mode 100644 index 00000000000..dab2d43fc90 --- /dev/null +++ b/spec/graphql/types/group_invitation_type_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::GroupInvitationType do + specify { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Group) } + + specify { expect(described_class.graphql_name).to eq('GroupInvitation') } + + specify { expect(described_class).to require_graphql_authorizations(:read_group) } + + it 'has the expected fields' do + expected_fields = %w[ + email access_level created_by created_at updated_at expires_at group + ] + + expect(described_class).to include_graphql_fields(*expected_fields) + end +end diff --git a/spec/graphql/types/invitation_interface_spec.rb b/spec/graphql/types/invitation_interface_spec.rb new file mode 100644 index 00000000000..8f345c58ca3 --- /dev/null +++ b/spec/graphql/types/invitation_interface_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::InvitationInterface do + it 'exposes the expected fields' do + expected_fields = %i[ + email + access_level + created_by + created_at + updated_at + expires_at + user + ] + + expect(described_class).to have_graphql_fields(*expected_fields) + end + + describe '.resolve_type' do + subject { described_class.resolve_type(object, {}) } + + context 'for project member' do + let(:object) { build(:project_member) } + + it { is_expected.to be Types::ProjectInvitationType } + end + + context 'for group member' do + let(:object) { build(:group_member) } + + it { is_expected.to be Types::GroupInvitationType } + end + + context 'for an unknown type' do + let(:object) { build(:user) } + + it 'raises an error' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::BaseError) + end + end + end +end diff --git a/spec/graphql/types/project_invitation_type_spec.rb b/spec/graphql/types/project_invitation_type_spec.rb new file mode 100644 index 00000000000..148a763a5fa --- /dev/null +++ b/spec/graphql/types/project_invitation_type_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::ProjectInvitationType do + specify { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Project) } + + specify { expect(described_class.graphql_name).to eq('ProjectInvitation') } + + specify { expect(described_class).to require_graphql_authorizations(:read_project) } + + it 'has the expected fields' do + expected_fields = %w[ + access_level created_by created_at updated_at expires_at project user + ] + + expect(described_class).to include_graphql_fields(*expected_fields) + end +end diff --git a/spec/lib/api/validations/validators/email_or_email_list_spec.rb b/spec/lib/api/validations/validators/email_or_email_list_spec.rb new file mode 100644 index 00000000000..ac3111c2319 --- /dev/null +++ b/spec/lib/api/validations/validators/email_or_email_list_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Validations::Validators::EmailOrEmailList do + include ApiValidatorsHelpers + + subject do + described_class.new(['email'], {}, false, scope.new) + end + + context 'with valid email addresses' do + it 'does not raise a validation error' do + expect_no_validation_error('test' => 'test@example.org') + expect_no_validation_error('test' => 'test1@example.com,test2@example.org') + expect_no_validation_error('test' => 'test1@example.com,test2@example.org,test3@example.co.uk') + end + end + + context 'including any invalid email address' do + it 'raises a validation error' do + expect_validation_error('test' => 'not') + expect_validation_error('test' => '@example.com') + expect_validation_error('test' => 'test1@example.com,asdf') + expect_validation_error('test' => 'asdf,testa1@example.com,asdf') + end + end +end diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb index a3977528537..9427fdfc0fe 100644 --- a/spec/lib/gitlab/path_regex_spec.rb +++ b/spec/lib/gitlab/path_regex_spec.rb @@ -107,7 +107,7 @@ RSpec.describe Gitlab::PathRegex do end let(:sitemap_words) do - %w(sitemap.xml sitemap.xml.gz) + %w(sitemap sitemap.xml sitemap.xml.gz) end let(:ee_top_level_words) do @@ -177,7 +177,7 @@ RSpec.describe Gitlab::PathRegex do # We ban new items in this list, see https://gitlab.com/gitlab-org/gitlab/-/issues/215362 it 'does not allow expansion' do - expect(described_class::TOP_LEVEL_ROUTES.size).to eq(43) + expect(described_class::TOP_LEVEL_ROUTES.size).to eq(44) end end diff --git a/spec/lib/gitlab/search/sort_options_spec.rb b/spec/lib/gitlab/search/sort_options_spec.rb new file mode 100644 index 00000000000..2044fdfc894 --- /dev/null +++ b/spec/lib/gitlab/search/sort_options_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require 'gitlab/search/sort_options' + +RSpec.describe ::Gitlab::Search::SortOptions do + describe '.sort_and_direction' do + context 'using order_by and sort' do + it 'returns matched options' do + expect(described_class.sort_and_direction('created_at', 'asc')).to eq(:created_at_asc) + expect(described_class.sort_and_direction('created_at', 'desc')).to eq(:created_at_desc) + end + end + + context 'using just sort' do + it 'returns matched options' do + expect(described_class.sort_and_direction(nil, 'created_asc')).to eq(:created_at_asc) + expect(described_class.sort_and_direction(nil, 'created_desc')).to eq(:created_at_desc) + end + end + + context 'when unknown option' do + it 'returns unknown' do + expect(described_class.sort_and_direction(nil, 'foo_asc')).to eq(:unknown) + expect(described_class.sort_and_direction(nil, 'bar_desc')).to eq(:unknown) + expect(described_class.sort_and_direction(nil, 'created_bar')).to eq(:unknown) + + expect(described_class.sort_and_direction('created_at', 'foo')).to eq(:unknown) + expect(described_class.sort_and_direction('foo', 'desc')).to eq(:unknown) + expect(described_class.sort_and_direction('created_at', nil)).to eq(:unknown) + end + end + end +end diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index d3a3f24732c..2e2c1806e51 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -308,6 +308,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do projects_with_tracing_enabled: 2, projects_with_error_tracking_enabled: 2 ) + expect(described_class.usage_activity_by_stage_monitor(described_class.last_28_days_time_period)).to include( clusters: 1, clusters_applications_prometheus: 1, @@ -470,6 +471,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do expect(count_data[:projects_with_prometheus_alerts]).to eq(2) expect(count_data[:projects_with_terraform_reports]).to eq(2) expect(count_data[:projects_with_terraform_states]).to eq(2) + expect(count_data[:projects_with_alerts_created]).to eq(1) expect(count_data[:protected_branches]).to eq(2) expect(count_data[:protected_branches_except_default]).to eq(1) expect(count_data[:terraform_reports]).to eq(6) @@ -611,6 +613,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do create(:deployment, :success, deployment_options) create(:project_snippet, project: project, created_at: n.days.ago) create(:personal_snippet, created_at: n.days.ago) + create(:alert_management_alert, project: project, created_at: n.days.ago) end stub_application_setting(self_monitoring_project: project) @@ -631,6 +634,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do expect(counts_monthly[:snippets]).to eq(2) expect(counts_monthly[:personal_snippets]).to eq(1) expect(counts_monthly[:project_snippets]).to eq(1) + expect(counts_monthly[:projects_with_alerts_created]).to eq(1) expect(counts_monthly[:packages]).to eq(1) expect(counts_monthly[:promoted_issues]).to eq(1) end diff --git a/spec/migrations/rename_sitemap_namespace_spec.rb b/spec/migrations/rename_sitemap_namespace_spec.rb new file mode 100644 index 00000000000..83f0721c600 --- /dev/null +++ b/spec/migrations/rename_sitemap_namespace_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20201102112206_rename_sitemap_namespace.rb') + +RSpec.describe RenameSitemapNamespace do + let(:namespaces) { table(:namespaces) } + let(:routes) { table(:routes) } + let(:sitemap_path) { 'sitemap' } + + it 'correctly run #up and #down' do + create_namespace(sitemap_path) + + reversible_migration do |migration| + migration.before -> { + expect(namespaces.pluck(:path)).to contain_exactly(sitemap_path) + } + + migration.after -> { + expect(namespaces.pluck(:path)).to contain_exactly(sitemap_path + '0') + } + end + end + + def create_namespace(path) + namespaces.create!(name: path, path: path).tap do |namespace| + routes.create!(path: namespace.path, name: namespace.name, source_id: namespace.id, source_type: 'Namespace') + end + end +end diff --git a/spec/models/concerns/from_union_spec.rb b/spec/models/concerns/from_union_spec.rb index bd2893090a8..4f4d948fe48 100644 --- a/spec/models/concerns/from_union_spec.rb +++ b/spec/models/concerns/from_union_spec.rb @@ -3,13 +3,5 @@ require 'spec_helper' RSpec.describe FromUnion do - [true, false].each do |sql_set_operator| - context "when sql-set-operators feature flag is #{sql_set_operator}" do - before do - stub_feature_flags(sql_set_operators: sql_set_operator) - end - - it_behaves_like 'from set operator', Gitlab::SQL::Union - end - end + it_behaves_like 'from set operator', Gitlab::SQL::Union end diff --git a/spec/requests/api/invitations_spec.rb b/spec/requests/api/invitations_spec.rb new file mode 100644 index 00000000000..9befd4f533a --- /dev/null +++ b/spec/requests/api/invitations_spec.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Invitations do + let(:maintainer) { create(:user, username: 'maintainer_user') } + let(:developer) { create(:user) } + let(:access_requester) { create(:user) } + let(:stranger) { create(:user) } + let(:email) { 'email@example.org' } + + let(:project) do + create(:project, :public, creator_id: maintainer.id, namespace: maintainer.namespace) do |project| + project.add_developer(developer) + project.add_maintainer(maintainer) + project.request_access(access_requester) + end + end + + let!(:group) do + create(:group, :public) do |group| + group.add_developer(developer) + group.add_owner(maintainer) + group.request_access(access_requester) + end + end + + def invitations_url(source, user) + api("/#{source.model_name.plural}/#{source.id}/invitations", user) + end + + shared_examples 'POST /:source_type/:id/invitations' do |source_type| + context "with :source_type == #{source_type.pluralize}" do + it_behaves_like 'a 404 response when source is private' do + let(:route) do + post invitations_url(source, stranger), + params: { email: email, access_level: Member::MAINTAINER } + end + end + + context 'when authenticated as a non-member or member with insufficient rights' do + %i[access_requester stranger developer].each do |type| + context "as a #{type}" do + it 'returns 403' do + user = public_send(type) + + post invitations_url(source, user), params: { email: email, access_level: Member::MAINTAINER } + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + end + end + + context 'when authenticated as a maintainer/owner' do + context 'and new member is already a requester' do + it 'does not transform the requester into a proper member' do + expect do + post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer), + params: { email: email, access_level: Member::MAINTAINER } + + expect(response).to have_gitlab_http_status(:created) + end.not_to change { source.members.count } + end + end + + it 'invites a new member' do + expect do + post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer), + params: { email: email, access_level: Member::DEVELOPER } + + expect(response).to have_gitlab_http_status(:created) + end.to change { source.requesters.count }.by(1) + end + + it 'invites a list of new email addresses' do + expect do + email_list = 'email1@example.com,email2@example.com' + + post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer), + params: { email: email_list, access_level: Member::DEVELOPER } + + expect(response).to have_gitlab_http_status(:created) + end.to change { source.requesters.count }.by(2) + end + end + + context 'access levels' do + it 'does not create the member if group level is higher' do + parent = create(:group) + + group.update!(parent: parent) + project.update!(group: group) + parent.add_developer(stranger) + + post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer), + params: { email: stranger.email, access_level: Member::REPORTER } + + expect(response).to have_gitlab_http_status(:created) + expect(json_response['message'][stranger.email]).to eq("Access level should be greater than or equal to Developer inherited membership from group #{parent.name}") + end + + it 'creates the member if group level is lower' do + parent = create(:group) + + group.update!(parent: parent) + project.update!(group: group) + parent.add_developer(stranger) + + post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer), + params: { email: stranger.email, access_level: Member::MAINTAINER } + + expect(response).to have_gitlab_http_status(:created) + end + end + + context 'access expiry date' do + subject do + post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer), + params: { email: email, access_level: Member::DEVELOPER, expires_at: expires_at } + end + + context 'when set to a date in the past' do + let(:expires_at) { 2.days.ago.to_date } + + it 'does not create a member' do + expect do + subject + end.not_to change { source.members.count } + + expect(response).to have_gitlab_http_status(:created) + expect(json_response['message'][email]).to eq('Expires at cannot be a date in the past') + end + end + + context 'when set to a date in the future' do + let(:expires_at) { 2.days.from_now.to_date } + + it 'invites a member' do + expect do + subject + end.to change { source.requesters.count }.by(1) + + expect(response).to have_gitlab_http_status(:created) + end + end + end + + it "returns a message if member already exists" do + post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer), + params: { email: maintainer.email, access_level: Member::MAINTAINER } + + expect(response).to have_gitlab_http_status(:created) + expect(json_response['message'][maintainer.email]).to eq("Already a member of #{source.name}") + end + + it 'returns 404 when the email is not valid' do + post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer), + params: { email: '', access_level: Member::MAINTAINER } + + expect(response).to have_gitlab_http_status(:created) + expect(json_response['message']).to eq('Email cannot be blank') + end + + it 'returns 404 when the email list is not a valid format' do + post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer), + params: { email: 'email1@example.com,not-an-email', access_level: Member::MAINTAINER } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to eq('email contains an invalid email address') + end + + it 'returns 400 when email is not given' do + post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer), + params: { access_level: Member::MAINTAINER } + + expect(response).to have_gitlab_http_status(:bad_request) + end + + it 'returns 400 when access_level is not given' do + post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer), + params: { email: email } + + expect(response).to have_gitlab_http_status(:bad_request) + end + + it 'returns 400 when access_level is not valid' do + post invitations_url(source, maintainer), + params: { email: email, access_level: non_existing_record_access_level } + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + end + + describe 'POST /projects/:id/invitations' do + it_behaves_like 'POST /:source_type/:id/invitations', 'project' do + let(:source) { project } + end + end + + describe 'POST /groups/:id/invitations' do + it_behaves_like 'POST /:source_type/:id/invitations', 'group' do + let(:source) { group } + end + end +end diff --git a/spec/requests/api/search_spec.rb b/spec/requests/api/search_spec.rb index de1aedc67a6..8012892a571 100644 --- a/spec/requests/api/search_spec.rb +++ b/spec/requests/api/search_spec.rb @@ -23,6 +23,48 @@ RSpec.describe API::Search do end end + shared_examples 'orderable by created_at' do |scope:| + it 'allows ordering results by created_at asc' do + get api(endpoint, user), params: { scope: scope, search: 'sortable', order_by: 'created_at', sort: 'asc' } + + expect(response).to have_gitlab_http_status(:success) + expect(json_response.count).to be > 1 + + created_ats = json_response.map { |r| Time.parse(r['created_at']) } + expect(created_ats.uniq.count).to be > 1 + + expect(created_ats).to eq(created_ats.sort) + end + + it 'allows ordering results by created_at desc' do + get api(endpoint, user), params: { scope: scope, search: 'sortable', order_by: 'created_at', sort: 'desc' } + + expect(response).to have_gitlab_http_status(:success) + expect(json_response.count).to be > 1 + + created_ats = json_response.map { |r| Time.parse(r['created_at']) } + expect(created_ats.uniq.count).to be > 1 + + expect(created_ats).to eq(created_ats.sort.reverse) + end + end + + shared_examples 'issues orderable by created_at' do + before do + create_list(:issue, 3, title: 'sortable item', project: project) + end + + it_behaves_like 'orderable by created_at', scope: :issues + end + + shared_examples 'merge_requests orderable by created_at' do + before do + create_list(:merge_request, 3, :unique_branches, title: 'sortable item', target_project: repo_project, source_project: repo_project) + end + + it_behaves_like 'orderable by created_at', scope: :merge_requests + end + shared_examples 'pagination' do |scope:, search: ''| it 'returns a different result for each page' do get api(endpoint, user), params: { scope: scope, search: search, page: 1, per_page: 1 } @@ -121,6 +163,8 @@ RSpec.describe API::Search do it_behaves_like 'ping counters', scope: :issues + it_behaves_like 'issues orderable by created_at' + describe 'pagination' do before do create(:issue, project: project, title: 'another issue') @@ -181,6 +225,8 @@ RSpec.describe API::Search do it_behaves_like 'ping counters', scope: :merge_requests + it_behaves_like 'merge_requests orderable by created_at' + describe 'pagination' do before do create(:merge_request, source_project: repo_project, title: 'another mr', target_branch: 'another_branch') @@ -354,6 +400,8 @@ RSpec.describe API::Search do it_behaves_like 'ping counters', scope: :issues + it_behaves_like 'issues orderable by created_at' + describe 'pagination' do before do create(:issue, project: project, title: 'another issue') @@ -374,6 +422,8 @@ RSpec.describe API::Search do it_behaves_like 'ping counters', scope: :merge_requests + it_behaves_like 'merge_requests orderable by created_at' + describe 'pagination' do before do create(:merge_request, source_project: repo_project, title: 'another mr', target_branch: 'another_branch') @@ -506,6 +556,8 @@ RSpec.describe API::Search do it_behaves_like 'ping counters', scope: :issues + it_behaves_like 'issues orderable by created_at' + describe 'pagination' do before do create(:issue, project: project, title: 'another issue') @@ -536,6 +588,8 @@ RSpec.describe API::Search do it_behaves_like 'ping counters', scope: :merge_requests + it_behaves_like 'merge_requests orderable by created_at' + describe 'pagination' do before do create(:merge_request, source_project: repo_project, title: 'another mr', target_branch: 'another_branch') diff --git a/spec/services/members/invite_service_spec.rb b/spec/services/members/invite_service_spec.rb new file mode 100644 index 00000000000..12a1a54696b --- /dev/null +++ b/spec/services/members/invite_service_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Members::InviteService do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:project_user) { create(:user) } + + before do + project.add_maintainer(user) + end + + it 'adds an existing user to members' do + params = { email: project_user.email.to_s, access_level: Gitlab::Access::GUEST } + result = described_class.new(user, params).execute(project) + + expect(result[:status]).to eq(:success) + expect(project.users).to include project_user + end + + it 'creates a new user for an unknown email address' do + params = { email: 'email@example.org', access_level: Gitlab::Access::GUEST } + result = described_class.new(user, params).execute(project) + + expect(result[:status]).to eq(:success) + end + + it 'limits the number of emails to 100' do + emails = Array.new(101).map { |n| "email#{n}@example.com" } + params = { email: emails, access_level: Gitlab::Access::GUEST } + + result = described_class.new(user, params).execute(project) + + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq('Too many users specified (limit is 100)') + end + + it 'does not invite an invalid email' do + params = { email: project_user.id.to_s, access_level: Gitlab::Access::GUEST } + result = described_class.new(user, params).execute(project) + + expect(result[:status]).to eq(:error) + expect(result[:message][project_user.id.to_s]).to eq("Invite email is invalid") + expect(project.users).not_to include project_user + end + + it 'does not invite to an invalid access level' do + params = { email: project_user.email, access_level: -1 } + result = described_class.new(user, params).execute(project) + + expect(result[:status]).to eq(:error) + expect(result[:message][project_user.email]).to eq("Access level is not included in the list") + end + + it 'does not add a member with an existing invite' do + invited_member = create(:project_member, :invited, project: project) + + params = { email: invited_member.invite_email, + access_level: Gitlab::Access::GUEST } + result = described_class.new(user, params).execute(project) + + expect(result[:status]).to eq(:error) + expect(result[:message][invited_member.invite_email]).to eq("Member already invited to #{project.name}") + end +end