# 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) { 'email1@example.com' } let(:email2) { 'email2@example.com' } 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 def invite_member_by_email(source, source_type, email, created_by) create(:"#{source_type}_member", invite_token: '123', invite_email: email, source: source, user: nil, created_by: created_by) 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: access_requester.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.members.invite.count }.by(1) end it 'invites a list of new email addresses' do expect do email_list = [email, email2].join(',') 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.members.invite.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.members.invite.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 shared_examples 'GET /: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) { get invitations_url(source, stranger) } end %i[maintainer developer access_requester stranger].each do |type| context "when authenticated as a #{type}" do it 'returns 200' do user = public_send(type) get invitations_url(source, user) expect(response).to have_gitlab_http_status(:ok) expect(response).to include_pagination_headers expect(json_response).to be_an Array expect(json_response.size).to eq(0) end end end it 'avoids N+1 queries' do # Establish baseline get invitations_url(source, maintainer) control = ActiveRecord::QueryRecorder.new do get invitations_url(source, maintainer) end invite_member_by_email(source, source_type, email, maintainer) expect do get invitations_url(source, maintainer) end.not_to exceed_query_limit(control) end it 'does not find confirmed members' do get invitations_url(source, developer) expect(response).to have_gitlab_http_status(:ok) expect(response).to include_pagination_headers expect(json_response).to be_an Array expect(json_response.size).to eq(0) expect(json_response.map { |u| u['id'] }).not_to match_array [maintainer.id, developer.id] end it 'finds all members with no query string specified' do invite_member_by_email(source, source_type, email, developer) invite_member_by_email(source, source_type, email2, developer) get invitations_url(source, developer), params: { query: '' } expect(response).to have_gitlab_http_status(:ok) expect(response).to include_pagination_headers expect(json_response).to be_an Array expect(json_response.count).to eq(2) expect(json_response.map { |u| u['invite_email'] }).to match_array [email, email2] end it 'finds the invitation by invite_email with query string' do invite_member_by_email(source, source_type, email, developer) invite_member_by_email(source, source_type, email2, developer) get invitations_url(source, developer), params: { query: email } expect(response).to have_gitlab_http_status(:ok) expect(response).to include_pagination_headers expect(json_response).to be_an Array expect(json_response.count).to eq(1) expect(json_response.first['invite_email']).to eq(email) expect(json_response.first['created_by_name']).to eq(developer.name) expect(json_response.first['user_name']).to eq(nil) end end end describe 'GET /projects/:id/invitations' do it_behaves_like 'GET /:source_type/:id/invitations', 'project' do let(:source) { project } end end describe 'GET /groups/:id/invitations' do it_behaves_like 'GET /:source_type/:id/invitations', 'group' do let(:source) { group } end end shared_examples 'DELETE /:source_type/:id/invitations/:email' do |source_type| def invite_api(source, user, email) api("/#{source.model_name.plural}/#{source.id}/invitations/#{email}", user) end context "with :source_type == #{source_type.pluralize}" do let!(:invite) { invite_member_by_email(source, source_type, developer.email, developer) } it_behaves_like 'a 404 response when source is private' do let(:route) { delete api("/#{source_type.pluralize}/#{source.id}/invitations/#{invite.invite_email}", stranger) } end context 'when authenticated as a non-member or member with insufficient rights' do %i[access_requester stranger].each do |type| context "as a #{type}" do it 'returns 403' do user = public_send(type) delete invite_api(source, user, invite.invite_email) expect(response).to have_gitlab_http_status(:forbidden) end end end end context 'when authenticated as a member and deleting themself' do it 'does not delete the member' do expect do delete invite_api(source, developer, invite.invite_email) expect(response).to have_gitlab_http_status(:forbidden) end.not_to change { source.members.count } end end context 'when authenticated as a maintainer/owner' do it 'deletes the member and returns 204 with no content' do expect do delete invite_api(source, maintainer, invite.invite_email) expect(response).to have_gitlab_http_status(:no_content) end.to change { source.members.count }.by(-1) end end it 'returns 404 if member does not exist' do delete invite_api(source, maintainer, non_existing_record_id) expect(response).to have_gitlab_http_status(:not_found) end it 'returns 422 for a valid request if the resource was not destroyed' do allow_next_instance_of(::Members::DestroyService) do |instance| allow(instance).to receive(:execute).with(invite).and_return(invite) end delete invite_api(source, maintainer, invite.invite_email) expect(response).to have_gitlab_http_status(:unprocessable_entity) end end end describe 'DELETE /projects/:id/inviations/:email' do it_behaves_like 'DELETE /:source_type/:id/invitations/:email', 'project' do let(:source) { project } end end describe 'DELETE /groups/:id/inviations/:email' do it_behaves_like 'DELETE /:source_type/:id/invitations/:email', 'group' do let(:source) { group } end end end