Commit 8fe421e0 authored by Alex Kalderimis's avatar Alex Kalderimis

Merge branch '323078-combine-concepts-in-inviting-member-service-classes-5' into 'master'

Refactor member/invitation services to share common code

See merge request gitlab-org/gitlab!57618
parents 08dab512 aed7f466
...@@ -62,7 +62,7 @@ class Admin::GroupsController < Admin::ApplicationController ...@@ -62,7 +62,7 @@ class Admin::GroupsController < Admin::ApplicationController
def members_update def members_update
member_params = params.permit(:user_ids, :access_level, :expires_at) member_params = params.permit(:user_ids, :access_level, :expires_at)
result = Members::CreateService.new(current_user, member_params.merge(limit: -1)).execute(@group) result = Members::CreateService.new(current_user, member_params.merge(limit: -1, source: @group)).execute
if result[:status] == :success if result[:status] == :success
redirect_to [:admin, @group], notice: _('Users were successfully added.') redirect_to [:admin, @group], notice: _('Users were successfully added.')
......
...@@ -6,7 +6,7 @@ module MembershipActions ...@@ -6,7 +6,7 @@ module MembershipActions
def create def create
create_params = params.permit(:user_ids, :access_level, :expires_at) create_params = params.permit(:user_ids, :access_level, :expires_at)
result = Members::CreateService.new(current_user, create_params).execute(membershipable) result = Members::CreateService.new(current_user, create_params.merge({ source: membershipable })).execute
if result[:status] == :success if result[:status] == :success
redirect_to members_page_url, notice: _('Users were successfully added.') redirect_to members_page_url, notice: _('Users were successfully added.')
......
...@@ -284,6 +284,10 @@ class Member < ApplicationRecord ...@@ -284,6 +284,10 @@ class Member < ApplicationRecord
Gitlab::Access.sym_options Gitlab::Access.sym_options
end end
def valid_email?(email)
Devise.email_regexp.match?(email)
end
private private
def parse_users_list(source, list) def parse_users_list(source, list)
...@@ -305,6 +309,7 @@ class Member < ApplicationRecord ...@@ -305,6 +309,7 @@ class Member < ApplicationRecord
if user_ids.present? if user_ids.present?
users.concat(User.where(id: user_ids)) users.concat(User.where(id: user_ids))
# the below will automatically discard invalid user_ids
existing_members = source.members_and_requesters.where(user_id: user_ids).index_by(&:user_id) existing_members = source.members_and_requesters.where(user_id: user_ids).index_by(&:user_id)
end end
......
...@@ -2,67 +2,98 @@ ...@@ -2,67 +2,98 @@
module Members module Members
class CreateService < Members::BaseService class CreateService < Members::BaseService
include Gitlab::Utils::StrongMemoize BlankInvitesError = Class.new(StandardError)
TooManyInvitesError = Class.new(StandardError)
DEFAULT_LIMIT = 100 DEFAULT_INVITE_LIMIT = 100
def execute(source) def initialize(*args)
return error(s_('AddMember|No users specified.')) if user_ids.blank? super
return error(s_("AddMember|Too many users specified (limit is %{user_limit})") % { user_limit: user_limit }) if @errors = []
user_limit && user_ids.size > user_limit @invites = invites_from_params&.split(',')&.uniq&.flatten
@source = params[:source]
end
def execute
validate_invites!
add_members
enqueue_onboarding_progress_action
result
rescue BlankInvitesError, TooManyInvitesError => e
error(e.message)
end
private
attr_reader :source, :errors, :invites, :member_created_namespace_id
def invites_from_params
params[:user_ids]
end
def validate_invites!
raise BlankInvitesError, blank_invites_message if invites.blank?
return unless user_limit && invites.size > user_limit
raise TooManyInvitesError,
format(s_("AddMember|Too many users specified (limit is %{user_limit})"), user_limit: user_limit)
end
def blank_invites_message
s_('AddMember|No users specified.')
end
def add_members
members = source.add_users( members = source.add_users(
user_ids, invites,
params[:access_level], params[:access_level],
expires_at: params[:expires_at], expires_at: params[:expires_at],
current_user: current_user current_user: current_user
) )
errors = [] members.each { |member| process_result(member) }
end
members.each do |member|
if member.invalid?
current_error =
# Invited users may not have an associated user
if member.user.present?
"#{member.user.username}: "
else
""
end
current_error += member.errors.full_messages.to_sentence
errors << current_error
else
after_execute(member: member)
end
end
enqueue_onboarding_progress_action(source) if members.size > errors.size
return success unless errors.any?
error(errors.to_sentence) def process_result(member)
if member.invalid?
add_error_for_member(member)
else
after_execute(member: member)
@member_created_namespace_id ||= member.namespace_id
end
end end
private def add_error_for_member(member)
prefix = "#{member.user.username}: " if member.user.present?
def user_ids errors << "#{prefix}#{member.errors.full_messages.to_sentence}"
strong_memoize(:user_ids) do
ids = params[:user_ids] || ''
ids.split(',').uniq.flatten
end
end end
def user_limit def user_limit
limit = params.fetch(:limit, DEFAULT_LIMIT) limit = params.fetch(:limit, DEFAULT_INVITE_LIMIT)
limit && limit < 0 ? nil : limit limit && limit < 0 ? nil : limit
end end
def enqueue_onboarding_progress_action(source) def enqueue_onboarding_progress_action
namespace_id = source.is_a?(Project) ? source.namespace_id : source.id return unless member_created_namespace_id
Namespaces::OnboardingUserAddedWorker.perform_async(namespace_id)
Namespaces::OnboardingUserAddedWorker.perform_async(member_created_namespace_id)
end
def result
if errors.any?
error(formatted_errors)
else
success
end
end
def formatted_errors
errors.to_sentence
end end
end end
end end
......
# frozen_string_literal: true # frozen_string_literal: true
module Members module Members
class InviteService < Members::BaseService class InviteService < Members::CreateService
BlankEmailsError = Class.new(StandardError) extend ::Gitlab::Utils::Override
TooManyEmailsError = Class.new(StandardError)
def initialize(*args) def initialize(*args)
super super
@errors = {} @errors = {}
@emails = params[:email]&.split(',')&.uniq&.flatten
@source = params[:source]
end
def execute
validate_emails!
emails.each(&method(:process_email))
enqueue_onboarding_progress_action
result
rescue BlankEmailsError, TooManyEmailsError => e
error(e.message)
end end
private private
attr_reader :source, :errors, :emails, :member_created_namespace_id alias_method :formatted_errors, :errors
def validate_emails!
raise BlankEmailsError, s_('AddMember|Email cannot be blank') if emails.blank?
if user_limit && emails.size > user_limit
raise TooManyEmailsError, s_("AddMember|Too many users specified (limit is %{user_limit})") % { user_limit: user_limit }
end
end
def user_limit
limit = params.fetch(:limit, Members::CreateService::DEFAULT_LIMIT)
limit < 0 ? nil : limit
end
def process_email(email)
return if existing_member?(email)
return if existing_invite?(email)
return if existing_request?(email)
add_member(email)
end
def existing_member?(email)
existing_member = source.members.with_user_by_email(email).exists?
if existing_member
errors[email] = s_("AddMember|Already a member of %{source_name}") % { source_name: source.name }
return true
end
false def invites_from_params
params[:email]
end end
def existing_invite?(email) def validate_invites!
existing_invite = source.members.search_invite_email(email).exists? super
if existing_invite
errors[email] = s_("AddMember|Member already invited to %{source_name}") % { source_name: source.name }
return true
end
false
end
def existing_request?(email)
existing_request = source.requesters.with_user_by_email(email).exists?
if existing_request # we need the below due to add_users hitting Member#parse_users_list and ignoring invalid emails
errors[email] = s_("AddMember|Member cannot be invited because they already requested to join %{source_name}") % { source_name: source.name } # ideally we wouldn't need this, but we can't really change the add_users method
return true valid, invalid = invites.partition { |email| Member.valid_email?(email) }
end @invites = valid
false invalid.each { |email| errors[email] = s_('AddMember|Invite email is invalid') }
end end
def add_member(email) override :blank_invites_message
new_member = source.add_user(email, params[:access_level], current_user: current_user, expires_at: params[:expires_at]) def blank_invites_message
s_('AddMember|Emails cannot be blank')
if new_member.invalid?
errors[email] = new_member.errors.full_messages.to_sentence
else
after_execute(member: new_member)
@member_created_namespace_id ||= new_member.namespace_id
end
end end
def result override :add_error_for_member
if errors.any? def add_error_for_member(member)
error(errors) errors[invite_email(member)] = member.errors.full_messages.to_sentence
else
success
end
end end
def enqueue_onboarding_progress_action def invite_email(member)
return unless member_created_namespace_id member.invite_email || member.user.email
Namespaces::OnboardingUserAddedWorker.perform_async(member_created_namespace_id)
end end
end end
end end
Members::InviteService.prepend_if_ee('EE::Members::InviteService')
---
title: Refactor member/invitation services to share common code
merge_request: 57618
author:
type: other
...@@ -61,8 +61,8 @@ When there was any error sending the email: ...@@ -61,8 +61,8 @@ When there was any error sending the email:
{ {
"status": "error", "status": "error",
"message": { "message": {
"test@example.com": "Already invited", "test@example.com": "Invite email has already been taken",
"test2@example.com": "Member already exsists" "test2@example.com": "User already exists in source"
} }
} }
``` ```
......
...@@ -5,11 +5,12 @@ module GroupInviteMembers ...@@ -5,11 +5,12 @@ module GroupInviteMembers
def invite_members(group) def invite_members(group)
invite_params = { invite_params = {
source: group,
user_ids: emails_param[:emails]&.reject(&:blank?)&.join(','), user_ids: emails_param[:emails]&.reject(&:blank?)&.join(','),
access_level: Gitlab::Access::DEVELOPER access_level: Gitlab::Access::DEVELOPER
} }
result = Members::CreateService.new(current_user, invite_params).execute(group) result = Members::CreateService.new(current_user, invite_params).execute
::Gitlab::Tracking.event(self.class.name, 'invite_members', label: 'new_group_form') if result[:status] == :success ::Gitlab::Tracking.event(self.class.name, 'invite_members', label: 'new_group_form') if result[:status] == :success
end end
......
...@@ -12,7 +12,7 @@ module Registrations ...@@ -12,7 +12,7 @@ module Registrations
end end
def create def create
result = Members::CreateService.new(current_user, invite_params).execute(group) result = Members::CreateService.new(current_user, invite_params).execute
if result[:status] == :success if result[:status] == :success
experiment(:registrations_group_invite, actor: current_user) experiment(:registrations_group_invite, actor: current_user)
...@@ -37,6 +37,7 @@ module Registrations ...@@ -37,6 +37,7 @@ module Registrations
def invite_params def invite_params
{ {
source: group,
user_ids: emails_param[:emails]&.reject(&:blank?)&.join(','), user_ids: emails_param[:emails]&.reject(&:blank?)&.join(','),
access_level: Gitlab::Access::DEVELOPER access_level: Gitlab::Access::DEVELOPER
} }
......
...@@ -3,15 +3,30 @@ ...@@ -3,15 +3,30 @@
module EE module EE
module Members module Members
module CreateService module CreateService
extend ::Gitlab::Utils::Override private
def validate_invites!
super
check_quota!
end
override :execute def check_quota!
def execute(source) return unless invite_quota_exceeded?
if invite_quota_exceeded?(source, user_ids)
return error(s_("AddMember|Invite limit of %{daily_invites} per day exceeded") % { daily_invites: source.actual_limits.daily_invites })
end
super(source) raise ::Members::CreateService::TooManyInvitesError,
format(
s_("AddMember|Invite limit of %{daily_invites} per day exceeded"),
daily_invites: source.actual_limits.daily_invites
)
end
def invite_quota_exceeded?
return unless source.actual_limits.daily_invites
invite_count = ::Member.invite.created_today.in_hierarchy(source).count
source.actual_limits.exceeded?(:daily_invites, invite_count + invites.count)
end end
def after_execute(member:) def after_execute(member:)
...@@ -20,8 +35,6 @@ module EE ...@@ -20,8 +35,6 @@ module EE
log_audit_event(member: member) log_audit_event(member: member)
end end
private
def log_audit_event(member:) def log_audit_event(member:)
::AuditEventService.new( ::AuditEventService.new(
current_user, current_user,
...@@ -29,14 +42,6 @@ module EE ...@@ -29,14 +42,6 @@ module EE
action: :create action: :create
).for_member(member).security_event ).for_member(member).security_event
end end
def invite_quota_exceeded?(source, user_ids)
return unless source.actual_limits.daily_invites
invite_count = ::Member.invite.created_today.in_hierarchy(source).count
source.actual_limits.exceeded?(:daily_invites, invite_count + user_ids.count)
end
end end
end end
end end
# frozen_string_literal: true
module EE
module Members
module InviteService
private
def validate_emails!
super
if invite_quota_exceeded?
raise ::Members::InviteService::TooManyEmailsError,
s_("AddMember|Invite limit of %{daily_invites} per day exceeded") %
{ daily_invites: source.actual_limits.daily_invites }
end
end
def invite_quota_exceeded?
return unless source.actual_limits.daily_invites
invite_count = ::Member.invite.created_today.in_hierarchy(source).count
source.actual_limits.exceeded?(:daily_invites, invite_count + emails.count)
end
def after_execute(member:)
super
log_audit_event(member: member)
end
def log_audit_event(member:)
::AuditEventService.new(
current_user,
member.source,
action: :create
).for_member(member).security_event
end
end
end
end
...@@ -12,7 +12,7 @@ RSpec.describe Members::CreateService do ...@@ -12,7 +12,7 @@ RSpec.describe Members::CreateService do
let(:params) { { user_ids: project_users.map(&:id).join(','), access_level: Gitlab::Access::GUEST } } let(:params) { { user_ids: project_users.map(&:id).join(','), access_level: Gitlab::Access::GUEST } }
subject { described_class.new(user, params).execute(project) } subject { described_class.new(user, params.merge({ source: project })).execute }
before_all do before_all do
project.add_maintainer(user) project.add_maintainer(user)
......
...@@ -100,9 +100,9 @@ module API ...@@ -100,9 +100,9 @@ module API
authorize_admin_source!(source_type, source) authorize_admin_source!(source_type, source)
if params[:user_id].to_s.include?(',') if params[:user_id].to_s.include?(',')
create_service_params = params.except(:user_id).merge({ user_ids: params[:user_id] }) create_service_params = params.except(:user_id).merge({ user_ids: params[:user_id], source: source })
::Members::CreateService.new(current_user, create_service_params).execute(source) ::Members::CreateService.new(current_user, create_service_params).execute
elsif params[:user_id].present? elsif params[:user_id].present?
member = source.members.find_by(user_id: params[:user_id]) member = source.members.find_by(user_id: params[:user_id])
conflict!('Member already exists') if member conflict!('Member already exists') if member
......
...@@ -2022,21 +2022,15 @@ msgstr "" ...@@ -2022,21 +2022,15 @@ msgstr ""
msgid "AddContextCommits|Add/remove" msgid "AddContextCommits|Add/remove"
msgstr "" msgstr ""
msgid "AddMember|Already a member of %{source_name}" msgid "AddMember|Emails cannot be blank"
msgstr "" msgstr ""
msgid "AddMember|Email cannot be blank" msgid "AddMember|Invite email is invalid"
msgstr "" msgstr ""
msgid "AddMember|Invite limit of %{daily_invites} per day exceeded" msgid "AddMember|Invite limit of %{daily_invites} per day exceeded"
msgstr "" msgstr ""
msgid "AddMember|Member already invited to %{source_name}"
msgstr ""
msgid "AddMember|Member cannot be invited because they already requested to join %{source_name}"
msgstr ""
msgid "AddMember|No users specified." msgid "AddMember|No users specified."
msgstr "" msgstr ""
......
...@@ -438,6 +438,16 @@ RSpec.describe Member do ...@@ -438,6 +438,16 @@ RSpec.describe Member do
it { is_expected.to respond_to(:user_email) } it { is_expected.to respond_to(:user_email) }
end end
describe '.valid_email?' do
it 'is a valid email format' do
expect(described_class.valid_email?('foo')).to eq(false)
end
it 'is not a valid email format' do
expect(described_class.valid_email?('foo@example.com')).to eq(true)
end
end
describe '.add_user' do describe '.add_user' do
%w[project group].each do |source_type| %w[project group].each do |source_type|
context "when source is a #{source_type}" do context "when source is a #{source_type}" do
......
...@@ -102,7 +102,8 @@ RSpec.describe API::Invitations do ...@@ -102,7 +102,8 @@ RSpec.describe API::Invitations do
params: { email: stranger.email, access_level: Member::REPORTER } params: { email: stranger.email, access_level: Member::REPORTER }
expect(response).to have_gitlab_http_status(:created) 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}") 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 end
it 'creates the member if group level is lower' do it 'creates the member if group level is lower' do
...@@ -153,10 +154,10 @@ RSpec.describe API::Invitations do ...@@ -153,10 +154,10 @@ RSpec.describe API::Invitations do
it "returns a message if member already exists" do it "returns a message if member already exists" do
post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer), post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer),
params: { email: maintainer.email, access_level: Member::MAINTAINER } params: { email: developer.email, access_level: Member::MAINTAINER }
expect(response).to have_gitlab_http_status(:created) expect(response).to have_gitlab_http_status(:created)
expect(json_response['message'][maintainer.email]).to eq("Already a member of #{source.name}") expect(json_response['message'][developer.email]).to eq("User already exists in source")
end end
it 'returns 404 when the email is not valid' do it 'returns 404 when the email is not valid' do
...@@ -164,7 +165,7 @@ RSpec.describe API::Invitations do ...@@ -164,7 +165,7 @@ RSpec.describe API::Invitations do
params: { email: '', access_level: Member::MAINTAINER } params: { email: '', access_level: Member::MAINTAINER }
expect(response).to have_gitlab_http_status(:created) expect(response).to have_gitlab_http_status(:created)
expect(json_response['message']).to eq('Email cannot be blank') expect(json_response['message']).to eq('Emails cannot be blank')
end end
it 'returns 404 when the email list is not a valid format' do it 'returns 404 when the email list is not a valid format' do
......
...@@ -273,7 +273,7 @@ RSpec.describe API::Members do ...@@ -273,7 +273,7 @@ RSpec.describe API::Members do
user_ids = [stranger.id, access_requester.id].join(',') user_ids = [stranger.id, access_requester.id].join(',')
allow_next_instance_of(::Members::CreateService) do |service| allow_next_instance_of(::Members::CreateService) do |service|
expect(service).to receive(:execute).with(source).and_return({ status: :error, message: error_message }) expect(service).to receive(:execute).and_return({ status: :error, message: error_message })
end end
expect do expect do
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Members::CreateService, :clean_gitlab_redis_shared_state, :sidekiq_inline do RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_shared_state, :sidekiq_inline do
let_it_be(:source) { create(:project) } let_it_be(:source) { create(:project) }
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let_it_be(:member) { create(:user) } let_it_be(:member) { create(:user) }
...@@ -10,7 +10,7 @@ RSpec.describe Members::CreateService, :clean_gitlab_redis_shared_state, :sideki ...@@ -10,7 +10,7 @@ RSpec.describe Members::CreateService, :clean_gitlab_redis_shared_state, :sideki
let_it_be(:access_level) { Gitlab::Access::GUEST } let_it_be(:access_level) { Gitlab::Access::GUEST }
let(:params) { { user_ids: user_ids, access_level: access_level } } let(:params) { { user_ids: user_ids, access_level: access_level } }
subject(:execute_service) { described_class.new(user, params).execute(source) } subject(:execute_service) { described_class.new(user, params.merge({ source: source })).execute }
before do before do
if source.is_a?(Project) if source.is_a?(Project)
......
...@@ -48,14 +48,24 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_ ...@@ -48,14 +48,24 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
it 'returns an error' do it 'returns an error' do
expect_not_to_create_members expect_not_to_create_members
expect(result[:message]).to eq('Email cannot be blank') expect(result[:message]).to eq('Emails cannot be blank')
end end
end end
context 'when email param is not included' do context 'when email param is not included' do
it 'returns an error' do it 'returns an error' do
expect_not_to_create_members expect_not_to_create_members
expect(result[:message]).to eq('Email cannot be blank') expect(result[:message]).to eq('Emails cannot be blank')
end
end
context 'when email is not a valid email format' do
let(:params) { { email: '_bogus_' } }
it 'returns an error' do
expect { result }.not_to change(ProjectMember, :count)
expect(result[:status]).to eq(:error)
expect(result[:message][params[:email]]).to eq("Invite email is invalid")
end end
end end
...@@ -114,7 +124,8 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_ ...@@ -114,7 +124,8 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
it 'returns an error' do it 'returns an error' do
expect_not_to_create_members expect_not_to_create_members
expect(result[:message][project_user.email]).to eq("Access level is not included in the list") expect(result[:message][project_user.email])
.to eq("Access level is not included in the list")
end end
end end
...@@ -125,7 +136,8 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_ ...@@ -125,7 +136,8 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
it 'adds new email and returns an error for the already invited email' do it 'adds new email and returns an error for the already invited email' do
expect_to_create_members(count: 1) expect_to_create_members(count: 1)
expect(result[:status]).to eq(:error) expect(result[:status]).to eq(:error)
expect(result[:message][invited_member.invite_email]).to eq("Member already invited to #{project.name}") expect(result[:message][invited_member.invite_email])
.to eq("Invite email has already been taken")
expect(project.users).to include project_user expect(project.users).to include project_user
end end
end end
...@@ -138,7 +150,7 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_ ...@@ -138,7 +150,7 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
expect_to_create_members(count: 1) expect_to_create_members(count: 1)
expect(result[:status]).to eq(:error) expect(result[:status]).to eq(:error)
expect(result[:message][requested_member.user.email]) expect(result[:message][requested_member.user.email])
.to eq("Member cannot be invited because they already requested to join #{project.name}") .to eq("User already exists in source")
expect(project.users).to include project_user expect(project.users).to include project_user
end end
end end
...@@ -150,7 +162,8 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_ ...@@ -150,7 +162,8 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
it 'adds new email and returns an error for the already invited email' do it 'adds new email and returns an error for the already invited email' do
expect_to_create_members(count: 1) expect_to_create_members(count: 1)
expect(result[:status]).to eq(:error) expect(result[:status]).to eq(:error)
expect(result[:message][existing_member.user.email]).to eq("Already a member of #{project.name}") expect(result[:message][existing_member.user.email])
.to eq("User already exists in source")
expect(project.users).to include project_user expect(project.users).to include project_user
end end
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment