diff --git a/app/graphql/mutations/users/set_namespace_commit_email.rb b/app/graphql/mutations/users/set_namespace_commit_email.rb new file mode 100644 index 0000000000000000000000000000000000000000..72ef0635bb30c133cfab7450677bb496e9b884ce --- /dev/null +++ b/app/graphql/mutations/users/set_namespace_commit_email.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Mutations + module Users + class SetNamespaceCommitEmail < BaseMutation + graphql_name 'UserSetNamespaceCommitEmail' + + argument :namespace_id, + ::Types::GlobalIDType[::Namespace], + required: true, + description: 'ID of the namespace to set the namespace commit email for.' + + argument :email_id, + ::Types::GlobalIDType[::Email], + required: false, + description: 'ID of the email to set.' + + field :namespace_commit_email, + Types::Users::NamespaceCommitEmailType, + null: true, + description: 'User namespace commit email after mutation.' + + authorize :read_namespace + + def resolve(args) + namespace = authorized_find!(args[:namespace_id]) + args[:email_id] = args[:email_id].model_id + + result = ::Users::SetNamespaceCommitEmailService.new(current_user, namespace, args[:email_id], {}).execute + { + namespace_commit_email: result.payload[:namespace_commit_email], + errors: result.errors + } + end + + private + + def find_object(id) + GitlabSchema.object_from_id( + id, expected_type: [::Namespace, ::Namespaces::UserNamespace, ::Namespaces::ProjectNamespace]).sync + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index efc7bf89693e2e67d313a572649b006880d47435..c3e44eb65e7fa86a62bbde069fee02b3e715fdfc 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -181,6 +181,7 @@ class MutationType < BaseObject mount_mutation Mutations::Pages::MarkOnboardingComplete mount_mutation Mutations::SavedReplies::Destroy mount_mutation Mutations::Uploads::Delete + mount_mutation Mutations::Users::SetNamespaceCommitEmail end end diff --git a/app/models/user.rb b/app/models/user.rb index c5c0353e6f438f72ae106345e42904effb16563c..76b2bde0f22cc780bb0d46a64e6a18d8e826df8e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2298,6 +2298,12 @@ def abuse_metadata } end + def namespace_commit_email_for_namespace(namespace) + return if namespace.nil? + + namespace_commit_emails.find_by(namespace: namespace) + end + protected # override, from Devise::Validatable diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 1078eda38e7e1188593f843895cbc45172b2b66f..2fd198b8cf4593dceb24708bba0550c95d9ecc03 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -31,6 +31,7 @@ class UserPolicy < BasePolicy enable :read_user_groups enable :read_saved_replies enable :read_user_email_address + enable :admin_user_email_address end rule { default }.enable :read_user_profile diff --git a/app/services/users/set_namespace_commit_email_service.rb b/app/services/users/set_namespace_commit_email_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..30ee597120deed40149ada7dbabeaba166132066 --- /dev/null +++ b/app/services/users/set_namespace_commit_email_service.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Users + class SetNamespaceCommitEmailService + include Gitlab::Allowable + + attr_reader :current_user, :target_user, :namespace, :email_id + + def initialize(current_user, namespace, email_id, params) + @current_user = current_user + @target_user = params.delete(:user) || current_user + @namespace = namespace + @email_id = email_id + end + + def execute + return error(_('Namespace must be provided.')) if namespace.nil? + + unless can?(current_user, :admin_user_email_address, target_user) + return error(_("User doesn't exist or you don't have permission to change namespace commit emails.")) + end + + unless can?(target_user, :read_namespace, namespace) + return error(_("Namespace doesn't exist or you don't have permission.")) + end + + email = target_user.emails.find_by(id: email_id) unless email_id.nil? # rubocop: disable CodeReuse/ActiveRecord + existing_namespace_commit_email = target_user.namespace_commit_email_for_namespace(namespace) + if existing_namespace_commit_email.nil? + return error(_('Email must be provided.')) if email.nil? + + create_namespace_commit_email(email) + elsif email_id.nil? + remove_namespace_commit_email(existing_namespace_commit_email) + else + update_namespace_commit_email(existing_namespace_commit_email, email) + end + end + + private + + def remove_namespace_commit_email(namespace_commit_email) + namespace_commit_email.destroy + success(nil) + end + + def create_namespace_commit_email(email) + namespace_commit_email = ::Users::NamespaceCommitEmail.new( + user: target_user, + namespace: namespace, + email: email + ) + + save_namespace_commit_email(namespace_commit_email) + end + + def update_namespace_commit_email(namespace_commit_email, email) + namespace_commit_email.email = email + + save_namespace_commit_email(namespace_commit_email) + end + + def save_namespace_commit_email(namespace_commit_email) + if !namespace_commit_email.save + error_in_save(namespace_commit_email) + else + success(namespace_commit_email) + end + end + + def success(namespace_commit_email) + ServiceResponse.success(payload: { + namespace_commit_email: namespace_commit_email + }) + end + + def error(message) + ServiceResponse.error(message: message) + end + + def error_in_save(namespace_commit_email) + return error(_('Failed to save namespace commit email.')) if namespace_commit_email.errors.empty? + + error(namespace_commit_email.errors.full_messages.to_sentence) + end + end +end diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 7661f2ec9a625afb3458b6bf05dfd4f4c2e4ada4..25fa02ed74eba2713c3681135f3c1ac53d29d2be 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -6736,6 +6736,26 @@ Input type: `UserPreferencesUpdateInput` | `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | `userPreferences` | [`UserPreferences`](#userpreferences) | User preferences after mutation. | +### `Mutation.userSetNamespaceCommitEmail` + +Input type: `UserSetNamespaceCommitEmailInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `emailId` | [`EmailID`](#emailid) | ID of the email to set. | +| `namespaceId` | [`NamespaceID!`](#namespaceid) | ID of the namespace to set the namespace commit email for. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +| `namespaceCommitEmail` | [`NamespaceCommitEmail`](#namespacecommitemail) | User namespace commit email after mutation. | + ### `Mutation.vulnerabilityConfirm` Input type: `VulnerabilityConfirmInput` @@ -26890,6 +26910,12 @@ Duration between two instants, represented as a fractional number of seconds. For example: 12.3334. +### `EmailID` + +A `EmailID` is a global ID. It is encoded as a string. + +An example `EmailID` is: `"gid://gitlab/Email/1"`. + ### `EnvironmentID` A `EnvironmentID` is a global ID. It is encoded as a string. diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 313e37cb81a91bc14ac53e861c997afc3d3a10d0..cf20c75e331731916b3dfb845d28a6e9aa50a682 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -16769,6 +16769,9 @@ msgstr "" msgid "Email display name" msgstr "" +msgid "Email must be provided." +msgstr "" + msgid "Email not verified. Please verify your email in Salesforce." msgstr "" @@ -18773,6 +18776,9 @@ msgstr "" msgid "Failed to save merge conflicts resolutions. Please try again!" msgstr "" +msgid "Failed to save namespace commit email." +msgstr "" + msgid "Failed to save new settings" msgstr "" @@ -29669,6 +29675,12 @@ msgstr "" msgid "Namespace Limits" msgstr "" +msgid "Namespace doesn't exist or you don't have permission." +msgstr "" + +msgid "Namespace must be provided." +msgstr "" + msgid "Namespace or group to import repository into does not exist." msgstr "" @@ -49310,6 +49322,9 @@ msgstr "" msgid "User does not have permission to create a Security Policy project." msgstr "" +msgid "User doesn't exist or you don't have permission to change namespace commit emails." +msgstr "" + msgid "User has already been deactivated" msgstr "" diff --git a/spec/graphql/mutations/users/set_namespace_commit_email_spec.rb b/spec/graphql/mutations/users/set_namespace_commit_email_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..6d8e15ac791e6fbcf05b0ede636f7a7b4a4b7b62 --- /dev/null +++ b/spec/graphql/mutations/users/set_namespace_commit_email_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Users::SetNamespaceCommitEmail, feature_category: :user_profile do + include GraphqlHelpers + + let(:current_user) { create(:user) } + let(:group) { create(:group) } + let(:email) { create(:email, user: current_user) } + let(:input) { {} } + let(:namespace_id) { group.to_global_id } + let(:email_id) { email.to_global_id } + + shared_examples 'success' do + it 'creates namespace commit email with correct values' do + expect(resolve_mutation[:namespace_commit_email]) + .to have_attributes({ namespace_id: namespace_id.model_id.to_i, email_id: email_id.model_id.to_i }) + end + end + + describe '#resolve' do + subject(:resolve_mutation) do + described_class.new(object: nil, context: { current_user: current_user }, field: nil).resolve( + namespace_id: namespace_id, + email_id: email_id + ) + end + + context 'when current_user does not have permission' do + it 'raises an error' do + expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + .with_message(Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR) + end + end + + context 'when the user has permission' do + before do + group.add_reporter(current_user) + end + + context 'when the email does not belong to the target user' do + let(:email_id) { create(:email).to_global_id } + + it 'returns the validation error' do + expect(resolve_mutation[:errors]).to contain_exactly("Email must be provided.") + end + end + + context 'when namespace is a group' do + it_behaves_like 'success' + end + + context 'when namespace is a user' do + let(:namespace_id) { current_user.namespace.to_global_id } + + it_behaves_like 'success' + end + + context 'when namespace is a project' do + let_it_be(:project) { create(:project) } + + let(:namespace_id) { project.project_namespace.to_global_id } + + before do + project.add_reporter(current_user) + end + + it_behaves_like 'success' + end + end + end + + specify { expect(described_class).to require_graphql_authorizations(:read_namespace) } +end diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb index 94b7e2951671f6ef97fd8fef699ae69e824c46b8..9a2caeb74356f72340b2e70ac514d06b0a01b75e 100644 --- a/spec/policies/user_policy_spec.rb +++ b/spec/policies/user_policy_spec.rb @@ -253,10 +253,12 @@ context 'when admin mode is enabled', :enable_admin_mode do it { is_expected.to be_allowed(:read_user_email_address) } + it { is_expected.to be_allowed(:admin_user_email_address) } end context 'when admin mode is disabled' do it { is_expected.not_to be_allowed(:read_user_email_address) } + it { is_expected.not_to be_allowed(:admin_user_email_address) } end end @@ -265,10 +267,12 @@ subject { described_class.new(current_user, current_user) } it { is_expected.to be_allowed(:read_user_email_address) } + it { is_expected.to be_allowed(:admin_user_email_address) } end context "requesting a different user's" do it { is_expected.not_to be_allowed(:read_user_email_address) } + it { is_expected.not_to be_allowed(:admin_user_email_address) } end end end diff --git a/spec/requests/api/graphql/users/set_namespace_commit_email_spec.rb b/spec/requests/api/graphql/users/set_namespace_commit_email_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1db6f83ce4fe559477ff78b5c88c74dffe1e09c3 --- /dev/null +++ b/spec/requests/api/graphql/users/set_namespace_commit_email_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Setting namespace commit email', feature_category: :user_profile do + include GraphqlHelpers + + let(:current_user) { create(:user) } + let(:group) { create(:group, :public) } + let(:email) { create(:email, :confirmed, user: current_user) } + let(:input) { {} } + let(:namespace_id) { group.to_global_id } + let(:email_id) { email.to_global_id } + + let(:resource_or_permission_error) do + "The resource that you are attempting to access does not exist or you don't have permission to perform this action" + end + + let(:mutation) do + variables = { + namespace_id: namespace_id, + email_id: email_id + } + graphql_mutation(:user_set_namespace_commit_email, variables.merge(input), + <<-QL.strip_heredoc + namespaceCommitEmail { + email { + id + } + } + errors + QL + ) + end + + def mutation_response + graphql_mutation_response(:user_set_namespace_commit_email) + end + + shared_examples 'success' do + it 'creates a namespace commit email' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(mutation_response.dig('namespaceCommitEmail', 'email', 'id')).to eq(email.to_global_id.to_s) + expect(graphql_errors).to be_nil + end + end + + before do + group.add_reporter(current_user) + end + + context 'when current_user is nil' do + it 'returns the top level error' do + post_graphql_mutation(mutation, current_user: nil) + + expect(graphql_errors.first).to match a_hash_including( + 'message' => resource_or_permission_error) + end + end + + context 'when the user cannot access the namespace' do + let(:namespace_id) { create(:group).to_global_id } + + it 'returns the top level error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(graphql_errors).not_to be_empty + expect(graphql_errors.first).to match a_hash_including( + 'message' => resource_or_permission_error) + end + end + + context 'when the service returns an error' do + let(:email_id) { create(:email).to_global_id } + + it 'returns the error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(mutation_response['errors']).to contain_exactly("Email must be provided.") + expect(mutation_response['namespaceCommitEmail']).to be_nil + end + end + + context 'when namespace is a group' do + it_behaves_like 'success' + end + + context 'when namespace is a user' do + let(:namespace_id) { current_user.namespace.to_global_id } + + it_behaves_like 'success' + end + + context 'when namespace is a project' do + let_it_be(:project) { create(:project) } + + let(:namespace_id) { project.project_namespace.to_global_id } + + before do + project.add_reporter(current_user) + end + + it_behaves_like 'success' + end +end diff --git a/spec/services/users/set_namespace_commit_email_service_spec.rb b/spec/services/users/set_namespace_commit_email_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4f64d454ecb507534af4c5cce6288d4f6acf1552 --- /dev/null +++ b/spec/services/users/set_namespace_commit_email_service_spec.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Users::SetNamespaceCommitEmailService, feature_category: :user_profile do + include AfterNextHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:email) { create(:email, user: user) } + let_it_be(:existing_achievement) { create(:achievement, namespace: group) } + + let(:namespace) { group } + let(:current_user) { user } + let(:target_user) { user } + let(:email_id) { email.id } + let(:params) { { user: target_user } } + let(:service) { described_class.new(current_user, namespace, email_id, params) } + + before_all do + group.add_reporter(user) + end + + shared_examples 'success' do + it 'creates namespace commit email' do + result = service.execute + + expect(result.payload[:namespace_commit_email]).to be_a(Users::NamespaceCommitEmail) + expect(result.payload[:namespace_commit_email]).to be_persisted + end + end + + describe '#execute' do + context 'when current_user is not provided' do + let(:current_user) { nil } + + it 'returns error message' do + expect(service.execute.message) + .to eq("User doesn't exist or you don't have permission to change namespace commit emails.") + end + end + + context 'when current_user does not have permission to change namespace commit emails' do + let(:target_user) { create(:user) } + + it 'returns error message' do + expect(service.execute.message) + .to eq("User doesn't exist or you don't have permission to change namespace commit emails.") + end + end + + context 'when target_user does not have permission to access the namespace' do + let(:namespace) { create(:group) } + + it 'returns error message' do + expect(service.execute.message).to eq("Namespace doesn't exist or you don't have permission.") + end + end + + context 'when namespace is not provided' do + let(:namespace) { nil } + + it 'returns error message' do + expect(service.execute.message).to eq('Namespace must be provided.') + end + end + + context 'when target user is not current user' do + context 'when current user is an admin' do + let(:current_user) { create(:user, :admin) } + + context 'when admin mode is enabled', :enable_admin_mode do + it 'creates namespace commit email' do + result = service.execute + + expect(result.payload[:namespace_commit_email]).to be_a(Users::NamespaceCommitEmail) + expect(result.payload[:namespace_commit_email]).to be_persisted + end + end + + context 'when admin mode is not enabled' do + it 'returns error message' do + expect(service.execute.message) + .to eq("User doesn't exist or you don't have permission to change namespace commit emails.") + end + end + end + + context 'when current user is not an admin' do + let(:current_user) { create(:user) } + + it 'returns error message' do + expect(service.execute.message) + .to eq("User doesn't exist or you don't have permission to change namespace commit emails.") + end + end + end + + context 'when namespace commit email does not exist' do + context 'when email_id is not provided' do + let(:email_id) { nil } + + it 'returns error message' do + expect(service.execute.message).to eq('Email must be provided.') + end + end + + context 'when model save fails' do + before do + allow_next(::Users::NamespaceCommitEmail).to receive(:save).and_return(false) + end + + it 'returns error message' do + expect(service.execute.message).to eq('Failed to save namespace commit email.') + end + end + + context 'when namepsace is a group' do + it_behaves_like 'success' + end + + context 'when namespace is a user' do + let(:namespace) { current_user.namespace } + + it_behaves_like 'success' + end + + context 'when namespace is a project' do + let_it_be(:project) { create(:project) } + + let(:namespace) { project.project_namespace } + + before do + project.add_reporter(current_user) + end + + it_behaves_like 'success' + end + end + + context 'when namespace commit email already exists' do + let!(:existing_namespace_commit_email) do + create(:namespace_commit_email, + user: target_user, + namespace: namespace, + email: create(:email, user: target_user)) + end + + context 'when email_id is not provided' do + let(:email_id) { nil } + + it 'destroys the namespace commit email' do + result = service.execute + + expect(result.message).to be_nil + expect(result.payload[:namespace_commit_email]).to be_nil + end + end + + context 'and email_id is provided' do + let(:email_id) { create(:email, user: current_user).id } + + it 'updates namespace commit email' do + result = service.execute + + existing_namespace_commit_email.reload + + expect(result.payload[:namespace_commit_email]).to eq(existing_namespace_commit_email) + expect(existing_namespace_commit_email.email_id).to eq(email_id) + end + end + + context 'when model save fails' do + before do + allow_any_instance_of(::Users::NamespaceCommitEmail).to receive(:save).and_return(false) # rubocop:disable RSpec/AnyInstanceOf + end + + it 'returns generic error message' do + expect(service.execute.message).to eq('Failed to save namespace commit email.') + end + + context 'with model errors' do + before do + allow_any_instance_of(::Users::NamespaceCommitEmail).to receive_message_chain(:errors, :empty?).and_return(false) # rubocop:disable RSpec/AnyInstanceOf + allow_any_instance_of(::Users::NamespaceCommitEmail).to receive_message_chain(:errors, :full_messages, :to_sentence).and_return('Model error') # rubocop:disable RSpec/AnyInstanceOf + end + + it 'returns the model error message' do + expect(service.execute.message).to eq('Model error') + end + end + end + end + end +end