Commit b94c2151 authored by Thong Kuah's avatar Thong Kuah

Merge branch...

Merge branch '350999-deprecate-use-of-members-api-for-invite-modal-in-favor-of-invitations-api-7' into 'master'

Update Invitations Service to accept users

See merge request gitlab-org/gitlab!83534
parents 1165c5f5 07422cb2
......@@ -14,7 +14,7 @@ module Members
super
@errors = []
@invites = invites_from_params&.split(',')&.uniq&.flatten
@invites = invites_from_params
@source = params[:source]
end
......@@ -43,7 +43,9 @@ module Members
attr_reader :source, :errors, :invites, :member_created_namespace_id, :members
def invites_from_params
params[:user_ids]
return params[:user_ids] if params[:user_ids].is_a?(Array)
params[:user_ids]&.to_s&.split(',')&.uniq&.flatten || []
end
def validate_invite_source!
......@@ -87,6 +89,7 @@ module Members
end
end
# overridden
def add_error_for_member(member)
prefix = "#{member.user.username}: " if member.user.present?
......@@ -117,7 +120,8 @@ module Members
def create_tasks_to_be_done
return if params[:tasks_to_be_done].blank? || params[:tasks_project_id].blank?
valid_members = members.select { |member| member.valid? && member.member_task.valid? }
# Only create task issues for existing users. Tasks for new users are created when they signup.
valid_members = members.select { |member| member.valid? && member.member_task.valid? && member.user.present? }
return unless valid_members.present?
# We can take the first `member_task` here, since all tasks will have the same attributes needed
......
......@@ -7,6 +7,8 @@ module Members
def initialize(*args)
super
@invites += parsed_emails
@errors = {}
end
......@@ -14,38 +16,63 @@ module Members
alias_method :formatted_errors, :errors
def invites_from_params
params[:email]
def parsed_emails
# can't put this in the initializer since `invites_from_params` is called in super class
# and needs it
@parsed_emails ||= (formatted_param(params[:email]) || [])
end
def formatted_param(parameter)
parameter&.split(',')&.uniq&.flatten
end
def validate_invitable!
super
return if params[:email].blank?
# we need the below due to add_users hitting Members::CreatorService.parse_users_list and ignoring invalid emails
# ideally we wouldn't need this, but we can't really change the add_users method
valid, invalid = invites.partition { |email| Member.valid_email?(email) }
@invites = valid
invalid_emails.each { |email| errors[email] = s_('AddMember|Invite email is invalid') }
end
def invalid_emails
parsed_emails.each_with_object([]) do |email, invalid|
next if Member.valid_email?(email)
invalid.each { |email| errors[email] = s_('AddMember|Invite email is invalid') }
invalid << email
@invites.delete(email)
end
end
override :blank_invites_message
def blank_invites_message
s_('AddMember|Emails cannot be blank')
s_('AddMember|Invites cannot be blank')
end
override :add_error_for_member
def add_error_for_member(member)
errors[invite_email(member)] = member.errors.full_messages.to_sentence
errors[invited_object(member)] = member.errors.full_messages.to_sentence
end
override :create_tasks_to_be_done
def create_tasks_to_be_done
# Only create task issues for existing users. Tasks for new users are created when they signup.
end
def invited_object(member)
return member.invite_email if member.invite_email
def invite_email(member)
member.invite_email || member.user.email
# There is a case where someone was invited by email, but the `user` record exists.
# The member record returned will not have an invite_email attribute defined since
# the CreatorService finds `user` record sometimes by email.
# At that point we lose the info of whether this invite was done by `user` or by email.
# Here we will give preference to check invites by user_id first.
# There is also a case where a user could be invited by their email and
# at the same time via the API in the same request.
# This would would mean the same user is invited as user_id and email.
# However, that isn't as likely from the UI at least since the token generator checks
# for that case and doesn't allow email being used if the user exists as a record already.
if member.user_id.to_s.in?(invites)
member.user.username
else
member.user.all_emails.detect { |email| email.in?(invites) }
end
end
end
end
......@@ -2334,15 +2334,15 @@ msgstr ""
msgid "AddContextCommits|Add/remove"
msgstr ""
msgid "AddMember|Emails cannot be blank"
msgstr ""
msgid "AddMember|Invite email is invalid"
msgstr ""
msgid "AddMember|Invite limit of %{daily_invites} per day exceeded"
msgstr ""
msgid "AddMember|Invites cannot be blank"
msgstr ""
msgid "AddMember|No invite source provided."
msgstr ""
......
......@@ -240,7 +240,7 @@ RSpec.describe API::Invitations do
params: { email: '', access_level: Member::MAINTAINER }
expect(response).to have_gitlab_http_status(:created)
expect(json_response['message']).to eq('Emails cannot be blank')
expect(json_response['message']).to eq('Invites cannot be blank')
end
it 'returns 404 when the email list is not a valid format' do
......
......@@ -6,6 +6,7 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
let_it_be(:source, reload: true) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:member) { create(:user) }
let_it_be(:user_invited_by_id) { create(:user) }
let_it_be(:user_ids) { member.id.to_s }
let_it_be(:access_level) { Gitlab::Access::GUEST }
......@@ -49,6 +50,36 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(true)
end
context 'when user_id is passed as an integer' do
let(:user_ids) { member.id }
it 'successfully creates member' do
expect(execute_service[:status]).to eq(:success)
expect(source.users).to include member
expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(true)
end
end
context 'with user_ids as an array of integers' do
let(:user_ids) { [member.id, user_invited_by_id.id] }
it 'successfully creates members' do
expect(execute_service[:status]).to eq(:success)
expect(source.users).to include(member, user_invited_by_id)
expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(true)
end
end
context 'with user_ids as an array of strings' do
let(:user_ids) { [member.id.to_s, user_invited_by_id.id.to_s] }
it 'successfully creates members' do
expect(execute_service[:status]).to eq(:success)
expect(source.users).to include(member, user_invited_by_id)
expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(true)
end
end
context 'when executing on a group' do
let_it_be(:source) { create(:group) }
......@@ -191,6 +222,15 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
)
end
context 'when it is an invite by email passed to user_ids' do
let(:user_ids) { 'email@example.org' }
it 'does not create task issues' do
expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async)
execute_service
end
end
context 'when passing many user ids' do
before do
stub_licensed_features(multiple_issue_assignees: false)
......
......@@ -6,6 +6,7 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
let_it_be(:project, reload: true) { create(:project) }
let_it_be(:user) { project.first_owner }
let_it_be(:project_user) { create(:user) }
let_it_be(:user_invited_by_id) { create(:user) }
let_it_be(:namespace) { project.namespace }
let(:params) { {} }
......@@ -41,148 +42,422 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end
end
context 'when email is not a valid email' do
let(:params) { { email: '_bogus_' } }
context 'when invites are passed as array' do
context 'with emails' do
let(:params) { { email: %w[email@example.org email2@example.org] } }
it 'returns an error' do
expect_not_to_create_members
expect(result[:message]['_bogus_']).to eq("Invite email is invalid")
it 'successfully creates members' do
expect_to_create_members(count: 2)
expect(result[:status]).to eq(:success)
end
end
context 'with user_ids as integers' do
let(:params) { { user_ids: [project_user.id, user_invited_by_id.id] } }
it 'successfully creates members' do
expect_to_create_members(count: 2)
expect(result[:status]).to eq(:success)
end
end
it_behaves_like 'does not record an onboarding progress action'
context 'with user_ids as strings' do
let(:params) { { user_ids: [project_user.id.to_s, user_invited_by_id.id.to_s] } }
it 'successfully creates members' do
expect_to_create_members(count: 2)
expect(result[:status]).to eq(:success)
end
end
context 'with a mixture of emails and user_ids' do
let(:params) do
{ user_ids: [project_user.id, user_invited_by_id.id], email: %w[email@example.org email2@example.org] }
end
it 'successfully creates members' do
expect_to_create_members(count: 4)
expect(result[:status]).to eq(:success)
end
end
end
context 'when emails are passed as an array' do
let(:params) { { email: %w[email@example.org email2@example.org] } }
context 'with multiple invites passed as strings' do
context 'with emails' do
let(:params) { { email: 'email@example.org,email2@example.org' } }
it 'successfully creates members' do
expect_to_create_members(count: 2)
expect(result[:status]).to eq(:success)
it 'successfully creates members' do
expect_to_create_members(count: 2)
expect(result[:status]).to eq(:success)
end
end
context 'with user_ids' do
let(:params) { { user_ids: "#{project_user.id},#{user_invited_by_id.id}" } }
it 'successfully creates members' do
expect_to_create_members(count: 2)
expect(result[:status]).to eq(:success)
end
end
context 'with a mixture of emails and user_ids' do
let(:params) do
{ user_ids: "#{project_user.id},#{user_invited_by_id.id}", email: 'email@example.org,email2@example.org' }
end
it 'successfully creates members' do
expect_to_create_members(count: 4)
expect(result[:status]).to eq(:success)
end
end
end
context 'when emails are passed as an empty string' do
let(:params) { { email: '' } }
context 'when invites formats are mixed' do
context 'when user_ids is an array and emails is a string' do
let(:params) do
{ user_ids: [project_user.id, user_invited_by_id.id], email: 'email@example.org,email2@example.org' }
end
it 'successfully creates members' do
expect_to_create_members(count: 4)
expect(result[:status]).to eq(:success)
end
end
context 'when user_ids is a string and emails is an array' do
let(:params) do
{ user_ids: "#{project_user.id},#{user_invited_by_id.id}", email: %w[email@example.org email2@example.org] }
end
it 'returns an error' do
expect_not_to_create_members
expect(result[:message]).to eq('Emails cannot be blank')
it 'successfully creates members' do
expect_to_create_members(count: 4)
expect(result[:status]).to eq(:success)
end
end
end
context 'when email param is not included' do
it 'returns an error' do
expect_not_to_create_members
expect(result[:message]).to eq('Emails cannot be blank')
context 'when invites are passed in different formats' do
context 'when emails are passed as an empty string' do
let(:params) { { email: '' } }
it 'returns an error' do
expect_not_to_create_members
expect(result[:message]).to eq('Invites cannot be blank')
end
end
context 'when user_ids are passed as an empty string' do
let(:params) { { user_ids: '' } }
it 'returns an error' do
expect_not_to_create_members
expect(result[:message]).to eq('Invites cannot be blank')
end
end
context 'when user_ids and emails are both passed as empty strings' do
let(:params) { { user_ids: '', email: '' } }
it 'returns an error' do
expect_not_to_create_members
expect(result[:message]).to eq('Invites cannot be blank')
end
end
context 'when user_id is passed as an integer' do
let(:params) { { user_ids: project_user.id } }
it 'successfully creates member' do
expect_to_create_members(count: 1)
expect(result[:status]).to eq(:success)
end
end
context 'when invite params are not included' do
it 'returns an error' do
expect_not_to_create_members
expect(result[:message]).to eq('Invites cannot be blank')
end
end
end
context 'when email is not a valid email format' do
let(:params) { { email: '_bogus_' } }
context 'with singular email' 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")
it 'returns an error' do
expect_not_to_create_members
expect(result[:status]).to eq(:error)
expect(result[:message][params[:email]]).to eq("Invite email is invalid")
end
it_behaves_like 'does not record an onboarding progress action'
end
context 'with user_id and singular invalid email' do
let(:params) { { user_ids: project_user.id, email: '_bogus_' } }
it 'has partial success' do
expect_to_create_members(count: 1)
expect(project.users).to include project_user
expect(result[:status]).to eq(:error)
expect(result[:message][params[:email]]).to eq("Invite email is invalid")
end
end
end
context 'when duplicate email addresses are passed' do
let(:params) { { email: 'email@example.org,email@example.org' } }
context 'with duplicate invites' do
context 'with duplicate emails' do
let(:params) { { email: 'email@example.org,email@example.org' } }
it 'only creates one member per unique address' do
expect_to_create_members(count: 1)
expect(result[:status]).to eq(:success)
it 'only creates one member per unique invite' do
expect_to_create_members(count: 1)
expect(result[:status]).to eq(:success)
end
end
context 'with duplicate user ids' do
let(:params) { { user_ids: "#{project_user.id},#{project_user.id}" } }
it 'only creates one member per unique invite' do
expect_to_create_members(count: 1)
expect(result[:status]).to eq(:success)
end
end
context 'with duplicate member by adding as user id and email' do
let(:params) { { user_ids: project_user.id, email: project_user.email } }
it 'only creates one member per unique invite' do
expect_to_create_members(count: 1)
expect(result[:status]).to eq(:success)
end
end
end
context 'when observing email limits' do
let_it_be(:emails) { Array(1..101).map { |n| "email#{n}@example.com" } }
context 'when observing invite limits' do
context 'with emails and in general' do
let_it_be(:emails) { Array(1..101).map { |n| "email#{n}@example.com" } }
context 'when over the allowed default limit of emails' do
let(:params) { { email: emails } }
context 'when over the allowed default limit of emails' do
let(:params) { { email: emails } }
it 'limits the number of emails to 100' do
expect_not_to_create_members
expect(result[:message]).to eq('Too many users specified (limit is 100)')
it 'limits the number of emails to 100' do
expect_not_to_create_members
expect(result[:message]).to eq('Too many users specified (limit is 100)')
end
end
context 'when over the allowed custom limit of emails' do
let(:params) { { email: 'email@example.org,email2@example.org', limit: 1 } }
it 'limits the number of emails to the limit supplied' do
expect_not_to_create_members
expect(result[:message]).to eq('Too many users specified (limit is 1)')
end
end
context 'when limit allowed is disabled via limit param' do
let(:params) { { email: emails, limit: -1 } }
it 'does not limit number of emails' do
expect_to_create_members(count: 101)
expect(result[:status]).to eq(:success)
end
end
end
context 'when over the allowed custom limit of emails' do
let(:params) { { email: 'email@example.org,email2@example.org', limit: 1 } }
context 'with user_ids' do
let(:user_ids) { 1.upto(101).to_a.join(',') }
let(:params) { { user_ids: user_ids } }
it 'limits the number of emails to the limit supplied' do
it 'limits the number of users to 100' do
expect_not_to_create_members
expect(result[:message]).to eq('Too many users specified (limit is 1)')
expect(result[:message]).to eq('Too many users specified (limit is 100)')
end
end
end
context 'when limit allowed is disabled via limit param' do
let(:params) { { email: emails, limit: -1 } }
context 'with an existing user' do
context 'with email' do
let(:params) { { email: project_user.email } }
it 'does not limit number of emails' do
expect_to_create_members(count: 101)
it 'adds an existing user to members' do
expect_to_create_members(count: 1)
expect(result[:status]).to eq(:success)
expect(project.users).to include project_user
end
end
end
context 'when email belongs to an existing user' do
let(:params) { { email: project_user.email } }
context 'with user_id' do
let(:params) { { user_ids: project_user.id } }
it 'adds an existing user to members' do
expect_to_create_members(count: 1)
expect(result[:status]).to eq(:success)
expect(project.users).to include project_user
it_behaves_like 'records an onboarding progress action', :user_added
it 'adds an existing user to members' do
expect_to_create_members(count: 1)
expect(result[:status]).to eq(:success)
expect(project.users).to include project_user
end
context 'when assigning tasks to be done' do
let(:params) do
{ user_ids: project_user.id, tasks_to_be_done: %w(ci code), tasks_project_id: project.id }
end
it 'creates 2 task issues', :aggregate_failures do
expect(TasksToBeDone::CreateWorker)
.to receive(:perform_async)
.with(anything, user.id, [project_user.id])
.once
.and_call_original
expect { result }.to change { project.issues.count }.by(2)
expect(project.issues).to all have_attributes(project: project, author: user)
end
end
end
end
context 'when access level is not valid' do
let(:params) { { email: project_user.email, access_level: -1 } }
context 'with email' do
let(:params) { { email: project_user.email, access_level: -1 } }
it 'returns an error' do
expect_not_to_create_members
expect(result[:message][project_user.email])
.to eq("Access level is not included in the list")
it 'returns an error' do
expect_not_to_create_members
expect(result[:message][project_user.email]).to eq("Access level is not included in the list")
end
end
end
context 'when invite already exists for an included email' do
let!(:invited_member) { create(:project_member, :invited, project: project) }
let(:params) { { email: "#{invited_member.invite_email},#{project_user.email}" } }
context 'with user_id' do
let(:params) { { user_ids: user_invited_by_id.id, access_level: -1 } }
it 'adds new email and returns an error for the already invited email' do
expect_to_create_members(count: 1)
expect(result[:status]).to eq(:error)
expect(result[:message][invited_member.invite_email])
.to eq("The member's email address has already been taken")
expect(project.users).to include project_user
it 'returns an error' do
expect_not_to_create_members
expect(result[:message][user_invited_by_id.username]).to eq("Access level is not included in the list")
end
end
end
context 'when access request already exists for an included email' do
let!(:requested_member) { create(:project_member, :access_request, project: project) }
let(:params) { { email: "#{requested_member.user.email},#{project_user.email}" } }
context 'with a mix of user_id and email' do
let(:params) { { user_ids: user_invited_by_id.id, email: project_user.email, access_level: -1 } }
it 'adds new email and returns an error for the already invited email' do
expect_to_create_members(count: 1)
expect(result[:status]).to eq(:error)
expect(result[:message][requested_member.user.email])
.to eq("User already exists in source")
expect(project.users).to include project_user
it 'returns errors' do
expect_not_to_create_members
expect(result[:message][project_user.email]).to eq("Access level is not included in the list")
expect(result[:message][user_invited_by_id.username]).to eq("Access level is not included in the list")
end
end
end
context 'when email is already a member on the project' do
let!(:existing_member) { create(:project_member, :guest, project: project) }
let(:params) { { email: "#{existing_member.user.email},#{project_user.email}" } }
context 'when member already exists' do
context 'with email' do
let!(:invited_member) { create(:project_member, :invited, project: project) }
let(:params) { { email: "#{invited_member.invite_email},#{project_user.email}" } }
it 'adds new email and returns an error for the already invited email' do
expect_to_create_members(count: 1)
expect(result[:status]).to eq(:error)
expect(result[:message][invited_member.invite_email])
.to eq("The member's email address has already been taken")
expect(project.users).to include project_user
end
end
it 'adds new email and returns an error for the already invited email' do
expect_to_create_members(count: 1)
expect(result[:status]).to eq(:error)
expect(result[:message][existing_member.user.email])
.to eq("User already exists in source")
expect(project.users).to include project_user
context 'when email is already a member with a user on the project' do
let!(:existing_member) { create(:project_member, :guest, project: project) }
let(:params) { { email: "#{existing_member.user.email}" } }
it 'returns an error for the already invited email' do
expect_not_to_create_members
expect(result[:message][existing_member.user.email]).to eq("User already exists in source")
end
context 'when email belongs to an existing user as a secondary email' do
let(:secondary_email) { create(:email, email: 'secondary@example.com', user: existing_member.user) }
let(:params) { { email: "#{secondary_email.email}" } }
it 'returns an error for the already invited email' do
expect_not_to_create_members
expect(result[:message][secondary_email.email]).to eq("User already exists in source")
end
end
end
context 'with user_id that already exists' do
let!(:existing_member) { create(:project_member, project: project, user: project_user) }
let(:params) { { user_ids: existing_member.user_id } }
it 'does not add the member again and is successful' do
expect_to_create_members(count: 0)
expect(project.users).to include project_user
end
end
context 'with user_id that already exists with a lower access_level' do
let!(:existing_member) { create(:project_member, :developer, project: project, user: project_user) }
let(:params) { { user_ids: existing_member.user_id, access_level: ProjectMember::MAINTAINER } }
it 'does not add the member again and updates the access_level' do
expect_to_create_members(count: 0)
expect(project.users).to include project_user
expect(existing_member.reset.access_level).to eq ProjectMember::MAINTAINER
end
end
context 'with user_id that already exists with a higher access_level' do
let!(:existing_member) { create(:project_member, :developer, project: project, user: project_user) }
let(:params) { { user_ids: existing_member.user_id, access_level: ProjectMember::GUEST } }
it 'does not add the member again and updates the access_level' do
expect_to_create_members(count: 0)
expect(project.users).to include project_user
expect(existing_member.reset.access_level).to eq ProjectMember::GUEST
end
end
context 'with user_id that already exists in a parent group' do
let_it_be(:group) { create(:group) }
let_it_be(:group_member) { create(:group_member, :developer, source: group, user: project_user) }
let_it_be(:project, reload: true) { create(:project, group: group) }
let_it_be(:user) { project.creator }
before_all do
project.add_maintainer(user)
end
context 'when access_level is lower than inheriting member' do
let(:params) { { user_ids: group_member.user_id, access_level: ProjectMember::GUEST }}
it 'does not add the member and returns an error' do
msg = "Access level should be greater than or equal " \
"to Developer inherited membership from group #{group.name}"
expect_not_to_create_members
expect(result[:message][project_user.username]).to eq msg
end
end
context 'when access_level is the same as the inheriting member' do
let(:params) { { user_ids: group_member.user_id, access_level: ProjectMember::DEVELOPER }}
it 'adds the member with correct access_level' do
expect_to_create_members(count: 1)
expect(project.users).to include project_user
expect(project.project_members.last.access_level).to eq ProjectMember::DEVELOPER
end
end
context 'when access_level is greater than the inheriting member' do
let(:params) { { user_ids: group_member.user_id, access_level: ProjectMember::MAINTAINER }}
it 'adds the member with correct access_level' do
expect_to_create_members(count: 1)
expect(project.users).to include project_user
expect(project.project_members.last.access_level).to eq ProjectMember::MAINTAINER
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