diff options
Diffstat (limited to 'spec/requests/api/graphql/mutations')
45 files changed, 2261 insertions, 334 deletions
diff --git a/spec/requests/api/graphql/mutations/achievements/award_spec.rb b/spec/requests/api/graphql/mutations/achievements/award_spec.rb new file mode 100644 index 00000000000..9bc0751e924 --- /dev/null +++ b/spec/requests/api/graphql/mutations/achievements/award_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Achievements::Award, feature_category: :user_profile do + include GraphqlHelpers + + let_it_be(:developer) { create(:user) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:achievement) { create(:achievement, namespace: group) } + let_it_be(:recipient) { create(:user) } + + let(:mutation) { graphql_mutation(:achievements_award, params) } + let(:achievement_id) { achievement&.to_global_id } + let(:recipient_id) { recipient&.to_global_id } + let(:params) do + { + achievement_id: achievement_id, + user_id: recipient_id + } + end + + subject { post_graphql_mutation(mutation, current_user: current_user) } + + def mutation_response + graphql_mutation_response(:achievements_create) + end + + before_all do + group.add_developer(developer) + group.add_maintainer(maintainer) + end + + context 'when the user does not have permission' do + let(:current_user) { developer } + + it_behaves_like 'a mutation that returns a top-level access error' + + it 'does not create an achievement' do + expect { subject }.not_to change { Achievements::UserAchievement.count } + end + end + + context 'when the user has permission' do + let(:current_user) { maintainer } + + context 'when the params are invalid' do + let(:achievement) { nil } + + it 'returns the validation error' do + subject + + expect(graphql_errors.to_s).to include('invalid value for achievementId (Expected value to not be null)') + end + end + + context 'when the recipient_id is invalid' do + let(:recipient_id) { "gid://gitlab/User/#{non_existing_record_id}" } + + it 'returns the validation error' do + subject + + expect(graphql_data_at(:achievements_award, + :errors)).to include("Couldn't find User with 'id'=#{non_existing_record_id}") + end + end + + context 'when the achievement_id is invalid' do + let(:achievement_id) { "gid://gitlab/Achievements::Achievement/#{non_existing_record_id}" } + + it 'returns the validation error' do + subject + + expect(graphql_errors.to_s) + .to include("The resource that you are attempting to access does not exist or you don't have permission") + end + end + + context 'when the feature flag is disabled' do + before do + stub_feature_flags(achievements: false) + end + + it 'returns the relevant error' do + subject + + expect(graphql_errors.to_s) + .to include("The resource that you are attempting to access does not exist or you don't have permission") + end + end + + it 'creates an achievement' do + expect { subject }.to change { Achievements::UserAchievement.count }.by(1) + end + + it 'returns the new achievement' do + subject + + expect(graphql_data_at(:achievements_award, :user_achievement, :achievement, :id)) + .to eq(achievement.to_global_id.to_s) + expect(graphql_data_at(:achievements_award, :user_achievement, :user, :id)) + .to eq(recipient.to_global_id.to_s) + end + end +end diff --git a/spec/requests/api/graphql/mutations/achievements/delete_spec.rb b/spec/requests/api/graphql/mutations/achievements/delete_spec.rb new file mode 100644 index 00000000000..276da4f46a8 --- /dev/null +++ b/spec/requests/api/graphql/mutations/achievements/delete_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Achievements::Delete, feature_category: :user_profile do + include GraphqlHelpers + + let_it_be(:developer) { create(:user) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:group) { create(:group) } + + let!(:achievement) { create(:achievement, namespace: group) } + let(:mutation) { graphql_mutation(:achievements_delete, params) } + let(:achievement_id) { achievement&.to_global_id } + let(:params) { { achievement_id: achievement_id } } + + subject { post_graphql_mutation(mutation, current_user: current_user) } + + def mutation_response + graphql_mutation_response(:achievements_delete) + end + + before_all do + group.add_developer(developer) + group.add_maintainer(maintainer) + end + + context 'when the user does not have permission' do + let(:current_user) { developer } + + it_behaves_like 'a mutation that returns a top-level access error' + + it 'does not revoke any achievements' do + expect { subject }.not_to change { Achievements::Achievement.count } + end + end + + context 'when the user has permission' do + let(:current_user) { maintainer } + + context 'when the params are invalid' do + let(:achievement) { nil } + + it 'returns the validation error' do + subject + + expect(graphql_errors.to_s).to include('invalid value for achievementId (Expected value to not be null)') + end + end + + context 'when the achievement_id is invalid' do + let(:achievement_id) { "gid://gitlab/Achievements::Achievement/#{non_existing_record_id}" } + + it 'returns the validation error' do + subject + + expect(graphql_errors.to_s) + .to include("The resource that you are attempting to access does not exist or you don't have permission") + end + end + + context 'when the feature flag is disabled' do + before do + stub_feature_flags(achievements: false) + end + + it 'returns the relevant error' do + subject + + expect(graphql_errors.to_s) + .to include("The resource that you are attempting to access does not exist or you don't have permission") + end + end + + it 'deletes the achievement' do + expect { subject }.to change { Achievements::Achievement.count }.by(-1) + end + end +end diff --git a/spec/requests/api/graphql/mutations/achievements/revoke_spec.rb b/spec/requests/api/graphql/mutations/achievements/revoke_spec.rb new file mode 100644 index 00000000000..925a1bb9fcc --- /dev/null +++ b/spec/requests/api/graphql/mutations/achievements/revoke_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Achievements::Revoke, feature_category: :user_profile do + include GraphqlHelpers + + let_it_be(:developer) { create(:user) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:achievement) { create(:achievement, namespace: group) } + let_it_be(:user_achievement) { create(:user_achievement, achievement: achievement) } + + let(:mutation) { graphql_mutation(:achievements_revoke, params) } + let(:user_achievement_id) { user_achievement&.to_global_id } + let(:params) { { user_achievement_id: user_achievement_id } } + + subject { post_graphql_mutation(mutation, current_user: current_user) } + + def mutation_response + graphql_mutation_response(:achievements_create) + end + + before_all do + group.add_developer(developer) + group.add_maintainer(maintainer) + end + + context 'when the user does not have permission' do + let(:current_user) { developer } + + it_behaves_like 'a mutation that returns a top-level access error' + + it 'does not revoke any achievements' do + expect { subject }.not_to change { Achievements::UserAchievement.where(revoked_by_user_id: nil).count } + end + end + + context 'when the user has permission' do + let(:current_user) { maintainer } + + context 'when the params are invalid' do + let(:user_achievement) { nil } + + it 'returns the validation error' do + subject + + expect(graphql_errors.to_s).to include('invalid value for userAchievementId (Expected value to not be null)') + end + end + + context 'when the user_achievement_id is invalid' do + let(:user_achievement_id) { "gid://gitlab/Achievements::UserAchievement/#{non_existing_record_id}" } + + it 'returns the validation error' do + subject + + expect(graphql_errors.to_s) + .to include("The resource that you are attempting to access does not exist or you don't have permission") + end + end + + context 'when the feature flag is disabled' do + before do + stub_feature_flags(achievements: false) + end + + it 'returns the relevant error' do + subject + + expect(graphql_errors.to_s) + .to include("The resource that you are attempting to access does not exist or you don't have permission") + end + end + + it 'revokes an achievement' do + expect { subject }.to change { Achievements::UserAchievement.where(revoked_by_user_id: nil).count }.by(-1) + end + + it 'returns the revoked achievement' do + subject + + expect(graphql_data_at(:achievements_revoke, :user_achievement, :achievement, :id)) + .to eq(achievement.to_global_id.to_s) + expect(graphql_data_at(:achievements_revoke, :user_achievement, :revoked_by_user, :id)) + .to eq(current_user.to_global_id.to_s) + expect(graphql_data_at(:achievements_revoke, :user_achievement, :revoked_at)) + .not_to be_nil + end + end +end diff --git a/spec/requests/api/graphql/mutations/achievements/update_spec.rb b/spec/requests/api/graphql/mutations/achievements/update_spec.rb new file mode 100644 index 00000000000..b2bb01b564c --- /dev/null +++ b/spec/requests/api/graphql/mutations/achievements/update_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Achievements::Update, feature_category: :user_profile do + include GraphqlHelpers + include WorkhorseHelpers + + let_it_be(:developer) { create(:user) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:group) { create(:group) } + + let!(:achievement) { create(:achievement, namespace: group) } + let(:mutation) { graphql_mutation(:achievements_update, params) } + let(:achievement_id) { achievement&.to_global_id } + let(:params) { { achievement_id: achievement_id, name: 'GitLab', avatar: avatar } } + let(:avatar) { nil } + + subject { post_graphql_mutation_with_uploads(mutation, current_user: current_user) } + + def mutation_response + graphql_mutation_response(:achievements_update) + end + + before_all do + group.add_developer(developer) + group.add_maintainer(maintainer) + end + + context 'when the user does not have permission' do + let(:current_user) { developer } + + it_behaves_like 'a mutation that returns a top-level access error' + + it 'does not update the achievement' do + expect { subject }.not_to change { achievement.reload.name } + end + end + + context 'when the user has permission' do + let(:current_user) { maintainer } + + context 'when the params are invalid' do + let(:achievement) { nil } + + it 'returns the validation error' do + subject + + expect(graphql_errors.to_s).to include('invalid value for achievementId (Expected value to not be null)') + end + end + + context 'when the achievement_id is invalid' do + let(:achievement_id) { "gid://gitlab/Achievements::Achievement/#{non_existing_record_id}" } + + it 'returns the validation error' do + subject + + expect(graphql_errors.to_s) + .to include("The resource that you are attempting to access does not exist or you don't have permission") + end + end + + context 'when the feature flag is disabled' do + before do + stub_feature_flags(achievements: false) + end + + it 'returns the relevant permission error' do + subject + + expect(graphql_errors.to_s) + .to include("The resource that you are attempting to access does not exist or you don't have permission") + end + end + + context 'with a new avatar' do + let(:avatar) { fixture_file_upload("spec/fixtures/dk.png") } + + it 'updates the achievement' do + subject + + achievement.reload + + expect(achievement.name).to eq('GitLab') + expect(achievement.avatar.file).not_to be_nil + end + end + end +end diff --git a/spec/requests/api/graphql/mutations/admin/sidekiq_queues/delete_jobs_spec.rb b/spec/requests/api/graphql/mutations/admin/sidekiq_queues/delete_jobs_spec.rb index 64ea6d32f5f..b3d25155a6f 100644 --- a/spec/requests/api/graphql/mutations/admin/sidekiq_queues/delete_jobs_spec.rb +++ b/spec/requests/api/graphql/mutations/admin/sidekiq_queues/delete_jobs_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Deleting Sidekiq jobs', :clean_gitlab_redis_queues, feature_category: :not_owned do +RSpec.describe 'Deleting Sidekiq jobs', :clean_gitlab_redis_queues, feature_category: :shared do include GraphqlHelpers let_it_be(:admin) { create(:admin) } diff --git a/spec/requests/api/graphql/mutations/award_emojis/add_spec.rb b/spec/requests/api/graphql/mutations/award_emojis/add_spec.rb index fdbff0f93cd..18cc85d36e0 100644 --- a/spec/requests/api/graphql/mutations/award_emojis/add_spec.rb +++ b/spec/requests/api/graphql/mutations/award_emojis/add_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Adding an AwardEmoji', feature_category: :not_owned do +RSpec.describe 'Adding an AwardEmoji', feature_category: :shared do include GraphqlHelpers let_it_be(:current_user) { create(:user) } diff --git a/spec/requests/api/graphql/mutations/award_emojis/remove_spec.rb b/spec/requests/api/graphql/mutations/award_emojis/remove_spec.rb index e200bfc2d18..7ec2b061a88 100644 --- a/spec/requests/api/graphql/mutations/award_emojis/remove_spec.rb +++ b/spec/requests/api/graphql/mutations/award_emojis/remove_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Removing an AwardEmoji', feature_category: :not_owned do +RSpec.describe 'Removing an AwardEmoji', feature_category: :shared do include GraphqlHelpers let(:current_user) { create(:user) } diff --git a/spec/requests/api/graphql/mutations/award_emojis/toggle_spec.rb b/spec/requests/api/graphql/mutations/award_emojis/toggle_spec.rb index 6dba2b58357..7c6a487cdd0 100644 --- a/spec/requests/api/graphql/mutations/award_emojis/toggle_spec.rb +++ b/spec/requests/api/graphql/mutations/award_emojis/toggle_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Toggling an AwardEmoji', feature_category: :not_owned do +RSpec.describe 'Toggling an AwardEmoji', feature_category: :shared do include GraphqlHelpers let_it_be(:current_user) { create(:user) } diff --git a/spec/requests/api/graphql/mutations/ci/job_cancel_spec.rb b/spec/requests/api/graphql/mutations/ci/job/cancel_spec.rb index 468a9e57f56..abad1ae0812 100644 --- a/spec/requests/api/graphql/mutations/ci/job_cancel_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/job/cancel_spec.rb @@ -15,12 +15,12 @@ RSpec.describe "JobCancel", feature_category: :continuous_integration do id: job.to_global_id.to_s } graphql_mutation(:job_cancel, variables, - <<-QL + <<-QL errors job { id } - QL + QL ) end diff --git a/spec/requests/api/graphql/mutations/ci/job_play_spec.rb b/spec/requests/api/graphql/mutations/ci/job/play_spec.rb index 9ba80e51dee..0c700248f85 100644 --- a/spec/requests/api/graphql/mutations/ci/job_play_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/job/play_spec.rb @@ -18,7 +18,7 @@ RSpec.describe 'JobPlay', feature_category: :continuous_integration do let(:mutation) do graphql_mutation(:job_play, variables, - <<-QL + <<-QL errors job { id @@ -28,7 +28,7 @@ RSpec.describe 'JobPlay', feature_category: :continuous_integration do } } } - QL + QL ) end @@ -63,7 +63,7 @@ RSpec.describe 'JobPlay', feature_category: :continuous_integration do } end - it 'provides those variables to the job', :aggregated_errors do + it 'provides those variables to the job', :aggregate_failures do expect_next_instance_of(Ci::PlayBuildService) do |instance| expect(instance).to receive(:execute).with(an_instance_of(Ci::Build), variables[:variables]).and_call_original end diff --git a/spec/requests/api/graphql/mutations/ci/job_retry_spec.rb b/spec/requests/api/graphql/mutations/ci/job/retry_spec.rb index e49ee6f3163..4114c77491b 100644 --- a/spec/requests/api/graphql/mutations/ci/job_retry_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/job/retry_spec.rb @@ -16,12 +16,12 @@ RSpec.describe 'JobRetry', feature_category: :continuous_integration do id: job.to_global_id.to_s } graphql_mutation(:job_retry, variables, - <<-QL + <<-QL errors job { id } - QL + QL ) end @@ -57,12 +57,12 @@ RSpec.describe 'JobRetry', feature_category: :continuous_integration do } graphql_mutation(:job_retry, variables, - <<-QL + <<-QL errors job { id } - QL + QL ) end diff --git a/spec/requests/api/graphql/mutations/ci/job_unschedule_spec.rb b/spec/requests/api/graphql/mutations/ci/job/unschedule_spec.rb index 6868b0ea279..08e155e808b 100644 --- a/spec/requests/api/graphql/mutations/ci/job_unschedule_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/job/unschedule_spec.rb @@ -15,7 +15,7 @@ RSpec.describe 'JobUnschedule', feature_category: :continuous_integration do id: job.to_global_id.to_s } graphql_mutation(:job_unschedule, variables, - <<-QL + <<-QL errors job { id diff --git a/spec/requests/api/graphql/mutations/ci/job_artifact/bulk_destroy_spec.rb b/spec/requests/api/graphql/mutations/ci/job_artifact/bulk_destroy_spec.rb new file mode 100644 index 00000000000..4e25669a0ca --- /dev/null +++ b/spec/requests/api/graphql/mutations/ci/job_artifact/bulk_destroy_spec.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'BulkDestroy', feature_category: :build_artifacts do + include GraphqlHelpers + + let(:maintainer) { create(:user) } + let(:developer) { create(:user) } + let(:first_artifact) { create(:ci_job_artifact) } + let(:second_artifact) { create(:ci_job_artifact, project: project) } + let(:second_artifact_another_project) { create(:ci_job_artifact) } + let(:project) { first_artifact.job.project } + let(:ids) { [first_artifact.to_global_id.to_s] } + let(:not_authorized_project_error_message) 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 = { + project_id: project.to_global_id.to_s, + ids: ids + } + graphql_mutation(:bulk_destroy_job_artifacts, variables, <<~FIELDS) + destroyedCount + destroyedIds + errors + FIELDS + end + + let(:mutation_response) { graphql_mutation_response(:bulk_destroy_job_artifacts) } + + it 'fails to destroy the artifact if a user not in a project' do + post_graphql_mutation(mutation, current_user: maintainer) + + expect(graphql_errors).to include( + a_hash_including('message' => not_authorized_project_error_message) + ) + + expect(first_artifact.reload).to be_persisted + end + + context 'when the `ci_job_artifact_bulk_destroy` feature flag is disabled' do + before do + stub_feature_flags(ci_job_artifact_bulk_destroy: false) + project.add_maintainer(maintainer) + end + + it 'returns a resource not available error' do + post_graphql_mutation(mutation, current_user: maintainer) + + expect(graphql_errors).to contain_exactly( + hash_including( + 'message' => '`ci_job_artifact_bulk_destroy` feature flag is disabled.' + ) + ) + end + end + + context "when the user is a developer in a project" do + before do + project.add_developer(developer) + end + + it 'fails to destroy the artifact' do + post_graphql_mutation(mutation, current_user: developer) + + expect(graphql_errors).to include( + a_hash_including('message' => not_authorized_project_error_message) + ) + + expect(response).to have_gitlab_http_status(:success) + expect(first_artifact.reload).to be_persisted + end + end + + context "when the user is a maintainer in a project" do + before do + project.add_maintainer(maintainer) + end + + shared_examples 'failing mutation' do + it 'rejects the request' do + post_graphql_mutation(mutation, current_user: maintainer) + + expect(graphql_errors(mutation_response)).to include(expected_error_message) + + expected_not_found_artifacts.each do |artifact| + expect { artifact.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + expected_found_artifacts.each do |artifact| + expect(artifact.reload).to be_persisted + end + end + end + + it 'destroys the artifact' do + post_graphql_mutation(mutation, current_user: maintainer) + + expect(mutation_response).to include("destroyedCount" => 1, "destroyedIds" => [gid_string(first_artifact)]) + expect(response).to have_gitlab_http_status(:success) + expect { first_artifact.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + context "and one artifact doesn't belong to the project" do + let(:not_owned_artifact) { create(:ci_job_artifact) } + let(:ids) { [first_artifact.to_global_id.to_s, not_owned_artifact.to_global_id.to_s] } + let(:expected_error_message) { "Not all artifacts belong to requested project" } + let(:expected_not_found_artifacts) { [] } + let(:expected_found_artifacts) { [first_artifact, not_owned_artifact] } + + it_behaves_like 'failing mutation' + end + + context "and multiple artifacts belong to the maintainer's project" do + let(:ids) { [first_artifact.to_global_id.to_s, second_artifact.to_global_id.to_s] } + + it 'destroys all artifacts' do + post_graphql_mutation(mutation, current_user: maintainer) + + expect(mutation_response).to include( + "destroyedCount" => 2, + "destroyedIds" => [gid_string(first_artifact), gid_string(second_artifact)] + ) + + expect(response).to have_gitlab_http_status(:success) + expect { first_artifact.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { second_artifact.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context "and one artifact belongs to a different maintainer's project" do + let(:ids) { [first_artifact.to_global_id.to_s, second_artifact_another_project.to_global_id.to_s] } + let(:expected_found_artifacts) { [first_artifact, second_artifact_another_project] } + let(:expected_not_found_artifacts) { [] } + let(:expected_error_message) { "Not all artifacts belong to requested project" } + + it_behaves_like 'failing mutation' + end + + context "and not found" do + let(:ids) { [first_artifact.to_global_id.to_s, second_artifact.to_global_id.to_s] } + let(:not_found_ids) { expected_not_found_artifacts.map(&:id).join(',') } + let(:expected_error_message) { "Artifacts (#{not_found_ids}) not found" } + + before do + expected_not_found_artifacts.each(&:destroy!) + end + + context "with one artifact" do + let(:expected_not_found_artifacts) { [second_artifact] } + let(:expected_found_artifacts) { [first_artifact] } + + it_behaves_like 'failing mutation' + end + + context "with all artifact" do + let(:expected_not_found_artifacts) { [first_artifact, second_artifact] } + let(:expected_found_artifacts) { [] } + + it_behaves_like 'failing mutation' + end + end + + context 'when empty request' do + before do + project.add_maintainer(maintainer) + end + + context 'with nil value' do + let(:ids) { nil } + + it 'does nothing and returns empty answer' do + post_graphql_mutation(mutation, current_user: maintainer) + + expect_graphql_errors_to_include(/was provided invalid value for ids \(Expected value to not be null\)/) + end + end + + context 'with empty array' do + let(:ids) { [] } + + it 'raises argument error' do + post_graphql_mutation(mutation, current_user: maintainer) + + expect_graphql_errors_to_include(/IDs array of job artifacts can not be empty/) + end + end + end + + def gid_string(object) + Gitlab::GlobalId.build(object, id: object.id).to_s + end + end +end diff --git a/spec/requests/api/graphql/mutations/ci/job_token_scope/add_project_spec.rb b/spec/requests/api/graphql/mutations/ci/job_token_scope/add_project_spec.rb index 55e728b2141..8791d793cb4 100644 --- a/spec/requests/api/graphql/mutations/ci/job_token_scope/add_project_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/job_token_scope/add_project_spec.rb @@ -53,14 +53,29 @@ RSpec.describe 'CiJobTokenScopeAddProject', feature_category: :continuous_integr before do target_project.add_developer(current_user) + stub_feature_flags(frozen_outbound_job_token_scopes_override: false) end - it 'adds the target project to the job token scope' do + it 'adds the target project to the inbound job token scope' do expect do post_graphql_mutation(mutation, current_user: current_user) expect(response).to have_gitlab_http_status(:success) expect(mutation_response.dig('ciJobTokenScope', 'projects', 'nodes')).not_to be_empty - end.to change { Ci::JobToken::ProjectScopeLink.outbound.count }.by(1) + end.to change { Ci::JobToken::ProjectScopeLink.inbound.count }.by(1) + end + + context 'when FF frozen_outbound_job_token_scopes is disabled' do + before do + stub_feature_flags(frozen_outbound_job_token_scopes: false) + end + + it 'adds the target project to the outbound job token scope' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response.dig('ciJobTokenScope', 'projects', 'nodes')).not_to be_empty + end.to change { Ci::JobToken::ProjectScopeLink.outbound.count }.by(1) + end end context 'when invalid target project is provided' do diff --git a/spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb b/spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb index 99e55c44773..aa00069b241 100644 --- a/spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb @@ -5,6 +5,10 @@ require 'spec_helper' RSpec.describe 'ProjectCiCdSettingsUpdate', feature_category: :continuous_integration do include GraphqlHelpers + before do + stub_feature_flags(frozen_outbound_job_token_scopes_override: false) + end + let_it_be(:project) do create(:project, keep_latest_artifact: true, @@ -18,12 +22,11 @@ RSpec.describe 'ProjectCiCdSettingsUpdate', feature_category: :continuous_integr full_path: project.full_path, keep_latest_artifact: false, job_token_scope_enabled: false, - inbound_job_token_scope_enabled: false, - opt_in_jwt: true + inbound_job_token_scope_enabled: false } end - let(:mutation) { graphql_mutation(:ci_cd_settings_update, variables) } + let(:mutation) { graphql_mutation(:project_ci_cd_settings_update, variables) } context 'when unauthorized' do let(:user) { create(:user) } @@ -61,7 +64,36 @@ RSpec.describe 'ProjectCiCdSettingsUpdate', feature_category: :continuous_integr expect(project.keep_latest_artifact).to eq(false) end - it 'updates job_token_scope_enabled' do + describe 'ci_cd_settings_update deprecated mutation' do + let(:mutation) { graphql_mutation(:ci_cd_settings_update, variables) } + + it 'returns error' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_errors).to( + include( + hash_including('message' => '`remove_cicd_settings_update` feature flag is enabled.') + ) + ) + end + + context 'when remove_cicd_settings_update FF is disabled' do + before do + stub_feature_flags(remove_cicd_settings_update: false) + end + + it 'updates ci cd settings' do + post_graphql_mutation(mutation, current_user: user) + + project.reload + + expect(response).to have_gitlab_http_status(:success) + expect(project.keep_latest_artifact).to eq(false) + end + end + end + + it 'allows setting job_token_scope_enabled to false' do post_graphql_mutation(mutation, current_user: user) project.reload @@ -70,6 +102,50 @@ RSpec.describe 'ProjectCiCdSettingsUpdate', feature_category: :continuous_integr expect(project.ci_outbound_job_token_scope_enabled).to eq(false) end + context 'when job_token_scope_enabled: true' do + let(:variables) do + { + full_path: project.full_path, + keep_latest_artifact: false, + job_token_scope_enabled: true, + inbound_job_token_scope_enabled: false + } + end + + it 'prevents the update', :aggregate_failures do + project.update!(ci_outbound_job_token_scope_enabled: false) + post_graphql_mutation(mutation, current_user: user) + + project.reload + + expect(response).to have_gitlab_http_status(:success) + expect(graphql_errors).to( + include( + hash_including( + 'message' => 'job_token_scope_enabled can only be set to false' + ) + ) + ) + expect(project.ci_outbound_job_token_scope_enabled).to eq(false) + end + end + + context 'when FF frozen_outbound_job_token_scopes is disabled' do + before do + stub_feature_flags(frozen_outbound_job_token_scopes: false) + end + + it 'allows setting job_token_scope_enabled to true' do + project.update!(ci_outbound_job_token_scope_enabled: true) + post_graphql_mutation(mutation, current_user: user) + + project.reload + + expect(response).to have_gitlab_http_status(:success) + expect(project.ci_outbound_job_token_scope_enabled).to eq(false) + end + end + it 'does not update job_token_scope_enabled if not specified' do variables.except!(:job_token_scope_enabled) @@ -101,30 +177,6 @@ RSpec.describe 'ProjectCiCdSettingsUpdate', feature_category: :continuous_integr expect(response).to have_gitlab_http_status(:success) expect(project.ci_inbound_job_token_scope_enabled).to eq(true) end - - context 'when ci_inbound_job_token_scope disabled' do - before do - stub_feature_flags(ci_inbound_job_token_scope: false) - end - - it 'does not update inbound_job_token_scope_enabled' do - post_graphql_mutation(mutation, current_user: user) - - project.reload - - expect(response).to have_gitlab_http_status(:success) - expect(project.ci_inbound_job_token_scope_enabled).to eq(true) - end - end - end - - it 'updates ci_opt_in_jwt' do - post_graphql_mutation(mutation, current_user: user) - - project.reload - - expect(response).to have_gitlab_http_status(:success) - expect(project.ci_opt_in_jwt).to eq(true) end context 'when bad arguments are provided' do diff --git a/spec/requests/api/graphql/mutations/ci/runner/create_spec.rb b/spec/requests/api/graphql/mutations/ci/runner/create_spec.rb new file mode 100644 index 00000000000..1658c277ed0 --- /dev/null +++ b/spec/requests/api/graphql/mutations/ci/runner/create_spec.rb @@ -0,0 +1,313 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'RunnerCreate', feature_category: :runner_fleet do + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:group_owner) { create(:user) } + let_it_be(:admin) { create(:admin) } + + let_it_be(:group) { create(:group) } + let_it_be(:other_group) { create(:group) } + + let(:mutation_params) do + { + description: 'create description', + maintenance_note: 'create maintenance note', + maximum_timeout: 900, + access_level: 'REF_PROTECTED', + paused: true, + run_untagged: false, + tag_list: %w[tag1 tag2] + }.deep_merge(mutation_scope_params) + end + + let(:mutation) do + variables = { + **mutation_params + } + + graphql_mutation( + :runner_create, + variables, + <<-QL + runner { + ephemeralAuthenticationToken + + runnerType + description + maintenanceNote + paused + tagList + accessLevel + locked + maximumTimeout + runUntagged + } + errors + QL + ) + end + + let(:mutation_response) { graphql_mutation_response(:runner_create) } + + before do + group.add_owner(group_owner) + end + + shared_context 'when model is invalid returns error' do + let(:mutation_params) do + { + description: '', + maintenanceNote: '', + paused: true, + accessLevel: 'NOT_PROTECTED', + runUntagged: false, + tagList: [], + maximumTimeout: 1 + }.deep_merge(mutation_scope_params) + end + + it do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + + expect(mutation_response['errors']).to contain_exactly( + 'Tags list can not be empty when runner is not allowed to pick untagged jobs', + 'Maximum timeout needs to be at least 10 minutes' + ) + end + end + + shared_context 'when user does not have permissions' do + let(:current_user) { user } + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect_graphql_errors_to_include( + 'The resource that you are attempting to access does not exist ' \ + "or you don't have permission to perform this action" + ) + end + end + + shared_context 'when :create_runner_workflow_for_namespace feature flag is disabled' do + before do + stub_feature_flags(create_runner_workflow_for_namespace: [other_group]) + end + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect_graphql_errors_to_include('`create_runner_workflow_for_namespace` feature flag is disabled.') + end + end + + shared_examples 'when runner is created successfully' do + it do + expected_args = { user: current_user, params: anything } + expect_next_instance_of(::Ci::Runners::CreateRunnerService, expected_args) do |service| + expect(service).to receive(:execute).and_call_original + end + + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + + expect(mutation_response['errors']).to eq([]) + expect(mutation_response['runner']).not_to be_nil + mutation_params.except(:group_id, :project_id).each_key do |key| + expect(mutation_response['runner'][key.to_s.camelize(:lower)]).to eq mutation_params[key] + end + + expect(mutation_response['runner']['ephemeralAuthenticationToken']) + .to start_with Ci::Runner::CREATED_RUNNER_TOKEN_PREFIX + end + end + + context 'when runnerType is INSTANCE_TYPE' do + let(:mutation_scope_params) do + { runner_type: 'INSTANCE_TYPE' } + end + + it_behaves_like 'when user does not have permissions' + + context 'when user has permissions', :enable_admin_mode do + let(:current_user) { admin } + + context 'when :create_runner_workflow_for_admin feature flag is disabled' do + before do + stub_feature_flags(create_runner_workflow_for_admin: false) + end + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect_graphql_errors_to_include('`create_runner_workflow_for_admin` feature flag is disabled.') + end + end + + it_behaves_like 'when runner is created successfully' + it_behaves_like 'when model is invalid returns error' + end + end + + context 'when runnerType is GROUP_TYPE' do + let(:mutation_scope_params) do + { + runner_type: 'GROUP_TYPE', + group_id: group.to_global_id + } + end + + before do + stub_feature_flags(create_runner_workflow_for_namespace: [group]) + end + + it_behaves_like 'when user does not have permissions' + + context 'when user has permissions' do + context 'when user is group owner' do + let(:current_user) { group_owner } + + it_behaves_like 'when :create_runner_workflow_for_namespace feature flag is disabled' + it_behaves_like 'when runner is created successfully' + it_behaves_like 'when model is invalid returns error' + + context 'when group_id is missing' do + let(:mutation_scope_params) do + { runner_type: 'GROUP_TYPE' } + end + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect_graphql_errors_to_include('`group_id` is missing') + end + end + + context 'when group_id is malformed' do + let(:mutation_scope_params) do + { + runner_type: 'GROUP_TYPE', + group_id: '' + } + end + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect_graphql_errors_to_include( + "RunnerCreateInput! was provided invalid value for groupId" + ) + end + end + + context 'when group_id does not exist' do + let(:mutation_scope_params) do + { + runner_type: 'GROUP_TYPE', + group_id: "gid://gitlab/Group/#{non_existing_record_id}" + } + end + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(flattened_errors).not_to be_empty + end + end + end + + context 'when user is admin in admin mode', :enable_admin_mode do + let(:current_user) { admin } + + it_behaves_like 'when :create_runner_workflow_for_namespace feature flag is disabled' + it_behaves_like 'when runner is created successfully' + it_behaves_like 'when model is invalid returns error' + end + end + end + + context 'when runnerType is PROJECT_TYPE' do + let_it_be(:project) { create(:project, namespace: group) } + + let(:mutation_scope_params) do + { + runner_type: 'PROJECT_TYPE', + project_id: project.to_global_id + } + end + + it_behaves_like 'when user does not have permissions' + + context 'when user has permissions' do + context 'when user is group owner' do + let(:current_user) { group_owner } + + it_behaves_like 'when :create_runner_workflow_for_namespace feature flag is disabled' + it_behaves_like 'when runner is created successfully' + it_behaves_like 'when model is invalid returns error' + + context 'when project_id is missing' do + let(:mutation_scope_params) do + { runner_type: 'PROJECT_TYPE' } + end + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect_graphql_errors_to_include('`project_id` is missing') + end + end + + context 'when project_id is malformed' do + let(:mutation_scope_params) do + { + runner_type: 'PROJECT_TYPE', + project_id: '' + } + end + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect_graphql_errors_to_include( + "RunnerCreateInput! was provided invalid value for projectId" + ) + end + end + + context 'when project_id does not exist' do + let(:mutation_scope_params) do + { + runner_type: 'PROJECT_TYPE', + project_id: "gid://gitlab/Project/#{non_existing_record_id}" + } + end + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect_graphql_errors_to_include( + 'The resource that you are attempting to access does not exist ' \ + "or you don't have permission to perform this action" + ) + end + end + end + + context 'when user is admin in admin mode', :enable_admin_mode do + let(:current_user) { admin } + + it_behaves_like 'when :create_runner_workflow_for_namespace feature flag is disabled' + it_behaves_like 'when runner is created successfully' + it_behaves_like 'when model is invalid returns error' + end + end + end +end diff --git a/spec/requests/api/graphql/mutations/clusters/agent_tokens/agent_tokens/create_spec.rb b/spec/requests/api/graphql/mutations/clusters/agent_tokens/agent_tokens/create_spec.rb index f544cef8864..ef0d44395bf 100644 --- a/spec/requests/api/graphql/mutations/clusters/agent_tokens/agent_tokens/create_spec.rb +++ b/spec/requests/api/graphql/mutations/clusters/agent_tokens/agent_tokens/create_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Create a new cluster agent token', feature_category: :kubernetes_management do +RSpec.describe 'Create a new cluster agent token', feature_category: :deployment_management do include GraphqlHelpers let_it_be(:cluster_agent) { create(:cluster_agent) } diff --git a/spec/requests/api/graphql/mutations/clusters/agents/create_spec.rb b/spec/requests/api/graphql/mutations/clusters/agents/create_spec.rb index 66e6c5cc629..1d1e72dcff9 100644 --- a/spec/requests/api/graphql/mutations/clusters/agents/create_spec.rb +++ b/spec/requests/api/graphql/mutations/clusters/agents/create_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Create a new cluster agent', feature_category: :kubernetes_management do +RSpec.describe 'Create a new cluster agent', feature_category: :deployment_management do include GraphqlHelpers let(:project) { create(:project, :public, :repository) } diff --git a/spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb b/spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb index 27a566dfb8c..b70a6282a7a 100644 --- a/spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb +++ b/spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Delete a cluster agent', feature_category: :kubernetes_management do +RSpec.describe 'Delete a cluster agent', feature_category: :deployment_management do include GraphqlHelpers let(:cluster_agent) { create(:cluster_agent) } diff --git a/spec/requests/api/graphql/mutations/container_repository/destroy_spec.rb b/spec/requests/api/graphql/mutations/container_repository/destroy_spec.rb index 8b76c19cda6..ef159e41d3d 100644 --- a/spec/requests/api/graphql/mutations/container_repository/destroy_spec.rb +++ b/spec/requests/api/graphql/mutations/container_repository/destroy_spec.rb @@ -39,7 +39,7 @@ RSpec.describe 'Destroying a container repository', feature_category: :container expect(DeleteContainerRepositoryWorker) .not_to receive(:perform_async) - expect { subject }.to change { ::Packages::Event.count }.by(1) + subject expect(container_repository_mutation_response).to match_schema('graphql/container_repository') expect(container_repository_mutation_response['status']).to eq('DELETE_SCHEDULED') @@ -53,7 +53,7 @@ RSpec.describe 'Destroying a container repository', feature_category: :container expect(DeleteContainerRepositoryWorker) .not_to receive(:perform_async).with(user.id, container_repository.id) - expect { subject }.not_to change { ::Packages::Event.count } + subject expect(mutation_response).to be_nil end diff --git a/spec/requests/api/graphql/mutations/container_repository/destroy_tags_spec.rb b/spec/requests/api/graphql/mutations/container_repository/destroy_tags_spec.rb index 9e07a831076..0cb607e13ec 100644 --- a/spec/requests/api/graphql/mutations/container_repository/destroy_tags_spec.rb +++ b/spec/requests/api/graphql/mutations/container_repository/destroy_tags_spec.rb @@ -36,7 +36,7 @@ RSpec.describe 'Destroying a container repository tags', feature_category: :cont it 'destroys the container repository tags' do expect(Projects::ContainerRepository::DeleteTagsService) .to receive(:new).and_call_original - expect { subject }.to change { ::Packages::Event.count }.by(1) + subject expect(tag_names_response).to eq(tags) expect(errors_response).to eq([]) @@ -50,7 +50,7 @@ RSpec.describe 'Destroying a container repository tags', feature_category: :cont expect(Projects::ContainerRepository::DeleteTagsService) .not_to receive(:new) - expect { subject }.not_to change { ::Packages::Event.count } + subject expect(mutation_response).to be_nil end @@ -89,7 +89,7 @@ RSpec.describe 'Destroying a container repository tags', feature_category: :cont let(:tags) { Array.new(Mutations::ContainerRepositories::DestroyTags::LIMIT + 1, 'x') } it 'returns too many tags error' do - expect { subject }.not_to change { ::Packages::Event.count } + subject explanation = graphql_errors.dig(0, 'message') expect(explanation).to eq(Mutations::ContainerRepositories::DestroyTags::TOO_MANY_TAGS_ERROR_MESSAGE) @@ -113,7 +113,7 @@ RSpec.describe 'Destroying a container repository tags', feature_category: :cont it 'does not create a package event' do expect(::Packages::CreateEventService).not_to receive(:new) - expect { subject }.not_to change { ::Packages::Event.count } + subject end end end diff --git a/spec/requests/api/graphql/mutations/custom_emoji/create_spec.rb b/spec/requests/api/graphql/mutations/custom_emoji/create_spec.rb index ea2ce8a13e2..19a52086f34 100644 --- a/spec/requests/api/graphql/mutations/custom_emoji/create_spec.rb +++ b/spec/requests/api/graphql/mutations/custom_emoji/create_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Creation of a new Custom Emoji', feature_category: :not_owned do +RSpec.describe 'Creation of a new Custom Emoji', feature_category: :shared do include GraphqlHelpers let_it_be(:current_user) { create(:user) } diff --git a/spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb b/spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb index ad7a043909a..2623d3d8410 100644 --- a/spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb +++ b/spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Deletion of custom emoji', feature_category: :not_owned do +RSpec.describe 'Deletion of custom emoji', feature_category: :shared do include GraphqlHelpers let_it_be(:group) { create(:group) } diff --git a/spec/requests/api/graphql/mutations/design_management/update_spec.rb b/spec/requests/api/graphql/mutations/design_management/update_spec.rb new file mode 100644 index 00000000000..9558f2538f1 --- /dev/null +++ b/spec/requests/api/graphql/mutations/design_management/update_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "updating designs", feature_category: :design_management do + include GraphqlHelpers + include DesignManagementTestHelpers + + let_it_be(:issue) { create(:issue) } + let_it_be_with_reload(:design) { create(:design, description: 'old description', issue: issue) } + let_it_be(:developer) { create(:user, developer_projects: [issue.project]) } + + let(:user) { developer } + let(:description) { 'new description' } + + let(:mutation) do + input = { + id: design.to_global_id.to_s, + description: description + }.compact + + graphql_mutation(:design_management_update, input, <<~FIELDS) + errors + design { + description + descriptionHtml + } + FIELDS + end + + let(:update_design) { post_graphql_mutation(mutation, current_user: user) } + let(:mutation_response) { graphql_mutation_response(:design_management_update) } + + before do + enable_design_management + end + + it 'updates design' do + update_design + + expect(graphql_errors).not_to be_present + expect(mutation_response).to eq( + 'errors' => [], + 'design' => { + 'description' => description, + 'descriptionHtml' => "<p data-sourcepos=\"1:1-1:15\" dir=\"auto\">#{description}</p>" + } + ) + end + + context 'when the user is not allowed to update designs' do + let(:user) { create(:user) } + + it 'returns an error' do + update_design + + expect(graphql_errors).to be_present + end + end + + context 'when update fails' do + let(:description) { 'x' * 1_000_001 } + + it 'returns an error' do + update_design + + expect(graphql_errors).not_to be_present + expect(mutation_response).to eq( + 'errors' => ["Description is too long (maximum is 1000000 characters)"], + 'design' => { + 'description' => 'old description', + 'descriptionHtml' => '<p data-sourcepos="1:1-1:15" dir="auto">old description</p>' + } + ) + end + end +end diff --git a/spec/requests/api/graphql/mutations/issues/bulk_update_spec.rb b/spec/requests/api/graphql/mutations/issues/bulk_update_spec.rb index b9c83311908..b729585a89b 100644 --- a/spec/requests/api/graphql/mutations/issues/bulk_update_spec.rb +++ b/spec/requests/api/graphql/mutations/issues/bulk_update_spec.rb @@ -8,7 +8,9 @@ RSpec.describe 'Bulk update issues', feature_category: :team_planning do let_it_be(:developer) { create(:user) } let_it_be(:group) { create(:group).tap { |group| group.add_developer(developer) } } let_it_be(:project) { create(:project, group: group) } - let_it_be(:updatable_issues, reload: true) { create_list(:issue, 2, project: project) } + let_it_be(:label1) { create(:group_label, group: group) } + let_it_be(:label2) { create(:group_label, group: group) } + let_it_be(:updatable_issues, reload: true) { create_list(:issue, 2, project: project, label_ids: [label1.id]) } let_it_be(:milestone) { create(:milestone, group: group) } let(:parent) { project } @@ -21,10 +23,36 @@ RSpec.describe 'Bulk update issues', feature_category: :team_planning do let(:additional_arguments) do { assignee_ids: [current_user.to_gid.to_s], - milestone_id: milestone.to_gid.to_s + milestone_id: milestone.to_gid.to_s, + state_event: :CLOSE, + add_label_ids: [label2.to_gid.to_s], + remove_label_ids: [label1.to_gid.to_s], + subscription_event: :UNSUBSCRIBE } end + before_all do + updatable_issues.each { |i| i.subscribe(developer, project) } + end + + context 'when Gitlab is FOSS only' do + unless Gitlab.ee? + context 'when parent is a group' do + let(:parent) { group } + + it 'does not allow bulk updating issues at the group level' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(graphql_errors).to contain_exactly( + hash_including( + 'message' => match(/does not represent an instance of IssueParent/) + ) + ) + end + end + end + end + context 'when the `bulk_update_issues_mutation` feature flag is disabled' do before do stub_feature_flags(bulk_update_issues_mutation: false) @@ -67,6 +95,11 @@ RSpec.describe 'Bulk update issues', feature_category: :team_planning do updatable_issues.each(&:reload) end.to change { updatable_issues.flat_map(&:assignee_ids) }.from([]).to([current_user.id] * 2) .and(change { updatable_issues.map(&:milestone_id) }.from([nil] * 2).to([milestone.id] * 2)) + .and(change { updatable_issues.map(&:state) }.from(['opened'] * 2).to(['closed'] * 2)) + .and(change { updatable_issues.flat_map(&:label_ids) }.from([label1.id] * 2).to([label2.id] * 2)) + .and( + change { updatable_issues.map { |i| i.subscribed?(developer, project) } }.from([true] * 2).to([false] * 2) + ) expect(mutation_response).to include( 'updatedIssueCount' => updatable_issues.count @@ -88,37 +121,6 @@ RSpec.describe 'Bulk update issues', feature_category: :team_planning do end end - context 'when scoping to a parent group' do - let(:parent) { group } - - it 'updates all issues' do - expect do - post_graphql_mutation(mutation, current_user: current_user) - updatable_issues.each(&:reload) - end.to change { updatable_issues.flat_map(&:assignee_ids) }.from([]).to([current_user.id] * 2) - .and(change { updatable_issues.map(&:milestone_id) }.from([nil] * 2).to([milestone.id] * 2)) - - expect(mutation_response).to include( - 'updatedIssueCount' => updatable_issues.count - ) - end - - context 'when current user cannot read the specified group' do - let(:parent) { create(:group, :private) } - - it 'returns a resource not found error' do - post_graphql_mutation(mutation, current_user: current_user) - - expect(graphql_errors).to contain_exactly( - hash_including( - 'message' => "The resource that you are attempting to access does not exist or you don't have " \ - 'permission to perform this action' - ) - ) - end - end - end - context 'when setting arguments to null or none' do let(:additional_arguments) { { assignee_ids: [], milestone_id: nil } } diff --git a/spec/requests/api/graphql/mutations/issues/create_spec.rb b/spec/requests/api/graphql/mutations/issues/create_spec.rb index d2d2f0014d6..b5a9c549045 100644 --- a/spec/requests/api/graphql/mutations/issues/create_spec.rb +++ b/spec/requests/api/graphql/mutations/issues/create_spec.rb @@ -66,7 +66,6 @@ RSpec.describe 'Create an issue', feature_category: :team_planning do created_issue = Issue.last expect(created_issue.work_item_type.base_type).to eq('task') - expect(created_issue.issue_type).to eq('task') end end diff --git a/spec/requests/api/graphql/mutations/members/groups/bulk_update_spec.rb b/spec/requests/api/graphql/mutations/members/groups/bulk_update_spec.rb index ad70129a7bc..f15b52f53a3 100644 --- a/spec/requests/api/graphql/mutations/members/groups/bulk_update_spec.rb +++ b/spec/requests/api/graphql/mutations/members/groups/bulk_update_spec.rb @@ -5,126 +5,14 @@ require 'spec_helper' RSpec.describe 'GroupMemberBulkUpdate', feature_category: :subgroups do include GraphqlHelpers - let_it_be(:current_user) { create(:user) } - let_it_be(:user1) { create(:user) } - let_it_be(:user2) { create(:user) } - let_it_be(:group) { create(:group) } - let_it_be(:group_member1) { create(:group_member, group: group, user: user1) } - let_it_be(:group_member2) { create(:group_member, group: group, user: user2) } + let_it_be(:parent_group) { create(:group) } + let_it_be(:parent_group_member) { create(:group_member, group: parent_group) } + let_it_be(:group) { create(:group, parent: parent_group) } + let_it_be(:source) { group } + let_it_be(:member_type) { :group_member } let_it_be(:mutation_name) { :group_member_bulk_update } + let_it_be(:source_id_key) { 'group_id' } + let_it_be(:response_member_field) { 'groupMembers' } - let(:input) do - { - 'group_id' => group.to_global_id.to_s, - 'user_ids' => [user1.to_global_id.to_s, user2.to_global_id.to_s], - 'access_level' => 'GUEST' - } - end - - let(:extra_params) { { expires_at: 10.days.from_now } } - let(:input_params) { input.merge(extra_params) } - let(:mutation) { graphql_mutation(mutation_name, input_params) } - let(:mutation_response) { graphql_mutation_response(mutation_name) } - - context 'when user is not logged-in' do - it_behaves_like 'a mutation that returns a top-level access error' - end - - context 'when user is not an owner' do - before do - group.add_maintainer(current_user) - end - - it_behaves_like 'a mutation that returns a top-level access error' - end - - context 'when user is an owner' do - before do - group.add_owner(current_user) - end - - shared_examples 'updates the user access role' do - specify do - post_graphql_mutation(mutation, current_user: current_user) - - new_access_levels = mutation_response['groupMembers'].map { |member| member['accessLevel']['integerValue'] } - expect(response).to have_gitlab_http_status(:success) - expect(mutation_response['errors']).to be_empty - expect(new_access_levels).to all(be Gitlab::Access::GUEST) - end - end - - it_behaves_like 'updates the user access role' - - context 'when inherited members are passed' do - let_it_be(:subgroup) { create(:group, parent: group) } - let_it_be(:subgroup_member) { create(:group_member, group: subgroup) } - - let(:input) do - { - 'group_id' => group.to_global_id.to_s, - 'user_ids' => [user1.to_global_id.to_s, user2.to_global_id.to_s, subgroup_member.user.to_global_id.to_s], - 'access_level' => 'GUEST' - } - end - - it 'does not update the members' do - post_graphql_mutation(mutation, current_user: current_user) - - error = Mutations::Members::Groups::BulkUpdate::INVALID_MEMBERS_ERROR - expect(json_response['errors'].first['message']).to include(error) - end - end - - context 'when members count is more than the allowed limit' do - let(:max_members_update_limit) { 1 } - - before do - stub_const('Mutations::Members::Groups::BulkUpdate::MAX_MEMBERS_UPDATE_LIMIT', max_members_update_limit) - end - - it 'does not update the members' do - post_graphql_mutation(mutation, current_user: current_user) - - error = Mutations::Members::Groups::BulkUpdate::MAX_MEMBERS_UPDATE_ERROR - expect(json_response['errors'].first['message']).to include(error) - end - end - - context 'when the update service raises access denied error' do - before do - allow_next_instance_of(Members::UpdateService) do |instance| - allow(instance).to receive(:execute).and_raise(Gitlab::Access::AccessDeniedError) - end - end - - it 'does not update the members' do - post_graphql_mutation(mutation, current_user: current_user) - - expect(mutation_response['groupMembers']).to be_nil - expect(mutation_response['errors']) - .to contain_exactly("Unable to update members, please check user permissions.") - end - end - - context 'when the update service returns an error message' do - before do - allow_next_instance_of(Members::UpdateService) do |instance| - error_result = { - message: 'Expires at cannot be a date in the past', - status: :error, - members: [group_member1] - } - allow(instance).to receive(:execute).and_return(error_result) - end - end - - it 'will pass through the error' do - post_graphql_mutation(mutation, current_user: current_user) - - expect(mutation_response['groupMembers'].first['id']).to eq(group_member1.to_global_id.to_s) - expect(mutation_response['errors']).to contain_exactly('Expires at cannot be a date in the past') - end - end - end + it_behaves_like 'members bulk update mutation' end diff --git a/spec/requests/api/graphql/mutations/members/projects/bulk_update_spec.rb b/spec/requests/api/graphql/mutations/members/projects/bulk_update_spec.rb new file mode 100644 index 00000000000..cbef9715cbe --- /dev/null +++ b/spec/requests/api/graphql/mutations/members/projects/bulk_update_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'ProjectMemberBulkUpdate', feature_category: :projects do + include GraphqlHelpers + + let_it_be(:parent_group) { create(:group) } + let_it_be(:parent_group_member) { create(:group_member, group: parent_group) } + let_it_be(:project) { create(:project, group: parent_group) } + let_it_be(:source) { project } + let_it_be(:member_type) { :project_member } + let_it_be(:mutation_name) { :project_member_bulk_update } + let_it_be(:source_id_key) { 'project_id' } + let_it_be(:response_member_field) { 'projectMembers' } + + it_behaves_like 'members bulk update mutation' +end diff --git a/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb index b5f2042c42a..d41628704a1 100644 --- a/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb +++ b/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb @@ -106,7 +106,7 @@ RSpec.describe 'Setting assignees of a merge request', :assume_throttled, featur end context 'when passing an empty list of assignees' do - let(:db_query_limit) { 31 } + let(:db_query_limit) { 35 } let(:input) { { assignee_usernames: [] } } before do diff --git a/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/create_spec.rb b/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/create_spec.rb index bce57b47aab..d81744abe1b 100644 --- a/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/create_spec.rb +++ b/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/create_spec.rb @@ -19,7 +19,11 @@ RSpec.describe Mutations::Metrics::Dashboard::Annotations::Create, feature_categ graphql_mutation_response(:create_annotation) end - specify { expect(described_class).to require_graphql_authorizations(:create_metrics_dashboard_annotation) } + before do + stub_feature_flags(remove_monitor_metrics: false) + end + + specify { expect(described_class).to require_graphql_authorizations(:admin_metrics_dashboard_annotation) } context 'when annotation source is environment' do let(:mutation) do @@ -103,6 +107,15 @@ RSpec.describe Mutations::Metrics::Dashboard::Annotations::Create, feature_categ it_behaves_like 'an invalid argument to the mutation', argument_name: :environment_id end + + context 'when metrics dashboard feature is unavailable' do + before do + stub_feature_flags(remove_monitor_metrics: true) + end + + it_behaves_like 'a mutation that returns top-level errors', + errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR] + end end end diff --git a/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/delete_spec.rb b/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/delete_spec.rb index f505dc25dc0..09977cd19d7 100644 --- a/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/delete_spec.rb +++ b/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/delete_spec.rb @@ -17,7 +17,11 @@ RSpec.describe Mutations::Metrics::Dashboard::Annotations::Delete, feature_categ graphql_mutation_response(:delete_annotation) end - specify { expect(described_class).to require_graphql_authorizations(:delete_metrics_dashboard_annotation) } + before do + stub_feature_flags(remove_monitor_metrics: false) + end + + specify { expect(described_class).to require_graphql_authorizations(:admin_metrics_dashboard_annotation) } context 'when the user has permission to delete the annotation' do before do @@ -54,6 +58,15 @@ RSpec.describe Mutations::Metrics::Dashboard::Annotations::Delete, feature_categ expect(mutation_response['errors']).to eq([service_response[:message]]) end end + + context 'when metrics dashboard feature is unavailable' do + before do + stub_feature_flags(remove_monitor_metrics: true) + end + + it_behaves_like 'a mutation that returns top-level errors', + errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR] + end end context 'when the user does not have permission to delete the annotation' do diff --git a/spec/requests/api/graphql/mutations/notes/create/note_spec.rb b/spec/requests/api/graphql/mutations/notes/create/note_spec.rb index a6253ba424b..e6feba059c4 100644 --- a/spec/requests/api/graphql/mutations/notes/create/note_spec.rb +++ b/spec/requests/api/graphql/mutations/notes/create/note_spec.rb @@ -104,7 +104,8 @@ RSpec.describe 'Adding a Note', feature_category: :team_planning do end context 'as work item' do - let(:noteable) { create(:work_item, :issue, project: project) } + let_it_be(:project) { create(:project) } + let_it_be(:noteable) { create(:work_item, :issue, project: project) } context 'when using internal param' do let(:variables_extra) { { internal: true } } @@ -130,6 +131,20 @@ RSpec.describe 'Adding a Note', feature_category: :team_planning do it_behaves_like 'a mutation that returns top-level errors', errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR] end + + context 'when body contains quick actions' do + let_it_be(:noteable) { create(:work_item, :task, project: project) } + + let(:variables_extra) { {} } + + it_behaves_like 'work item supports labels widget updates via quick actions' + it_behaves_like 'work item does not support labels widget updates via quick actions' + it_behaves_like 'work item supports assignee widget updates via quick actions' + it_behaves_like 'work item does not support assignee widget updates via quick actions' + it_behaves_like 'work item supports start and due date widget updates via quick actions' + it_behaves_like 'work item does not support start and due date widget updates via quick actions' + it_behaves_like 'work item supports type change via quick actions' + end end end diff --git a/spec/requests/api/graphql/mutations/projects/sync_fork_spec.rb b/spec/requests/api/graphql/mutations/projects/sync_fork_spec.rb new file mode 100644 index 00000000000..c5dc6f390d9 --- /dev/null +++ b/spec/requests/api/graphql/mutations/projects/sync_fork_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe "Sync project fork", feature_category: :source_code_management do + include GraphqlHelpers + include ProjectForksHelper + include ExclusiveLeaseHelpers + + let_it_be(:source_project) { create(:project, :repository, :public) } + let_it_be(:current_user) { create(:user, maintainer_projects: [source_project]) } + let_it_be(:project, refind: true) { fork_project(source_project, current_user, { repository: true }) } + let_it_be(:target_branch) { project.default_branch } + + let(:mutation) do + params = { project_path: project.full_path, target_branch: target_branch } + + graphql_mutation(:project_sync_fork, params) do + <<-QL.strip_heredoc + details { + ahead + behind + isSyncing + hasConflicts + } + errors + QL + end + end + + before do + source_project.change_head('feature') + end + + context 'when synchronize_fork feature flag is disabled' do + before do + stub_feature_flags(synchronize_fork: false) + end + + it 'does not call the sync service' do + expect(::Projects::Forks::SyncWorker).not_to receive(:perform_async) + + post_graphql_mutation(mutation, current_user: current_user) + + expect(graphql_mutation_response(:project_sync_fork)).to eq( + { + 'details' => nil, + 'errors' => ['Feature flag is disabled'] + }) + end + end + + context 'when the branch is protected', :use_clean_rails_redis_caching do + let_it_be(:protected_branch) do + create(:protected_branch, :no_one_can_push, project: project, name: target_branch) + end + + it_behaves_like 'a mutation that returns a top-level access error' + + it 'does not call the sync service' do + expect(::Projects::Forks::SyncWorker).not_to receive(:perform_async) + + post_graphql_mutation(mutation, current_user: current_user) + end + end + + context 'when the user does not have permission' do + let_it_be(:current_user) { create(:user) } + + it_behaves_like 'a mutation that returns a top-level access error' + + it 'does not call the sync service' do + expect(::Projects::Forks::SyncWorker).not_to receive(:perform_async) + + post_graphql_mutation(mutation, current_user: current_user) + end + end + + context 'when the user has permission' do + context 'and the sync service executes successfully', :sidekiq_inline do + it 'calls the sync service' do + expect(::Projects::Forks::SyncWorker).to receive(:perform_async).and_call_original + + post_graphql_mutation(mutation, current_user: current_user) + + expect(graphql_mutation_response(:project_sync_fork)).to eq( + { + 'details' => { 'ahead' => 30, 'behind' => 0, "hasConflicts" => false, "isSyncing" => false }, + 'errors' => [] + }) + end + end + + context 'and the sync service fails to execute' do + let(:target_branch) { 'markdown' } + + def expect_error_response(message) + expect(::Projects::Forks::SyncWorker).not_to receive(:perform_async) + + post_graphql_mutation(mutation, current_user: current_user) + + expect(graphql_mutation_response(:project_sync_fork)['errors']).to eq([message]) + end + + context 'when fork details cannot be resolved' do + let_it_be(:project) { source_project } + + it 'returns an error' do + expect_error_response('This branch of this project cannot be updated from the upstream') + end + end + + context 'when the specified branch does not exist' do + let(:target_branch) { 'non-existent-branch' } + + it 'returns an error' do + expect_error_response('Target branch does not exist') + end + end + + context 'when the previous execution resulted in a conflict' do + it 'returns an error' do + expect_next_instance_of(::Projects::Forks::Details) do |instance| + expect(instance).to receive(:has_conflicts?).twice.and_return(true) + end + + expect_error_response('The synchronization cannot happen due to the merge conflict') + expect(graphql_mutation_response(:project_sync_fork)['details']['hasConflicts']).to eq(true) + end + end + + context 'when the request is rate limited' do + it 'returns an error' do + expect(Gitlab::ApplicationRateLimiter).to receive(:throttled?).and_return(true) + + expect_error_response('This service has been called too many times.') + end + end + + context 'when another fork sync is in progress' do + it 'returns an error' do + expect_next_instance_of(Projects::Forks::Details) do |instance| + lease = instance_double(Gitlab::ExclusiveLease, try_obtain: false, exists?: true) + expect(instance).to receive(:exclusive_lease).twice.and_return(lease) + end + + expect_error_response('Another fork sync is already in progress') + expect(graphql_mutation_response(:project_sync_fork)['details']['isSyncing']).to eq(true) + end + end + end + end +end diff --git a/spec/requests/api/graphql/mutations/release_asset_links/create_spec.rb b/spec/requests/api/graphql/mutations/release_asset_links/create_spec.rb index 418a0e47a36..311ff48a846 100644 --- a/spec/requests/api/graphql/mutations/release_asset_links/create_spec.rb +++ b/spec/requests/api/graphql/mutations/release_asset_links/create_spec.rb @@ -32,7 +32,6 @@ RSpec.describe 'Creation of a new release asset link', feature_category: :releas url linkType directAssetUrl - external } errors FIELDS @@ -49,8 +48,7 @@ RSpec.describe 'Creation of a new release asset link', feature_category: :releas name: mutation_arguments[:name], url: mutation_arguments[:url], linkType: mutation_arguments[:linkType], - directAssetUrl: end_with(mutation_arguments[:directAssetPath]), - external: true + directAssetUrl: end_with(mutation_arguments[:directAssetPath]) }.with_indifferent_access expect(mutation_response[:link]).to include(expected_response) diff --git a/spec/requests/api/graphql/mutations/release_asset_links/delete_spec.rb b/spec/requests/api/graphql/mutations/release_asset_links/delete_spec.rb index b6d2c3f691d..cda1030c6d6 100644 --- a/spec/requests/api/graphql/mutations/release_asset_links/delete_spec.rb +++ b/spec/requests/api/graphql/mutations/release_asset_links/delete_spec.rb @@ -22,7 +22,6 @@ RSpec.describe 'Deletes a release asset link', feature_category: :release_orches url linkType directAssetUrl - external } errors FIELDS @@ -39,8 +38,7 @@ RSpec.describe 'Deletes a release asset link', feature_category: :release_orches name: release_link.name, url: release_link.url, linkType: release_link.link_type.upcase, - directAssetUrl: end_with(release_link.filepath), - external: true + directAssetUrl: end_with(release_link.filepath) }.with_indifferent_access expect(mutation_response[:link]).to match(expected_response) diff --git a/spec/requests/api/graphql/mutations/release_asset_links/update_spec.rb b/spec/requests/api/graphql/mutations/release_asset_links/update_spec.rb index 61395cc4042..45028cba3ae 100644 --- a/spec/requests/api/graphql/mutations/release_asset_links/update_spec.rb +++ b/spec/requests/api/graphql/mutations/release_asset_links/update_spec.rb @@ -40,7 +40,6 @@ RSpec.describe 'Updating an existing release asset link', feature_category: :rel url linkType directAssetUrl - external } errors FIELDS @@ -57,8 +56,7 @@ RSpec.describe 'Updating an existing release asset link', feature_category: :rel name: mutation_arguments[:name], url: mutation_arguments[:url], linkType: mutation_arguments[:linkType], - directAssetUrl: end_with(mutation_arguments[:directAssetPath]), - external: true + directAssetUrl: end_with(mutation_arguments[:directAssetPath]) }.with_indifferent_access expect(mutation_response[:link]).to include(expected_response) diff --git a/spec/requests/api/graphql/mutations/releases/create_spec.rb b/spec/requests/api/graphql/mutations/releases/create_spec.rb index 295b8c0e97e..7cb421f17a3 100644 --- a/spec/requests/api/graphql/mutations/releases/create_spec.rb +++ b/spec/requests/api/graphql/mutations/releases/create_spec.rb @@ -59,7 +59,6 @@ RSpec.describe 'Creation of a new release', feature_category: :release_orchestra name url linkType - external directAssetUrl } } @@ -135,7 +134,6 @@ RSpec.describe 'Creation of a new release', feature_category: :release_orchestra name: asset_link[:name], url: asset_link[:url], linkType: asset_link[:linkType], - external: true, directAssetUrl: expected_direct_asset_url }] } diff --git a/spec/requests/api/graphql/mutations/snippets/update_spec.rb b/spec/requests/api/graphql/mutations/snippets/update_spec.rb index fa087e6773c..3b98ee3c2e9 100644 --- a/spec/requests/api/graphql/mutations/snippets/update_spec.rb +++ b/spec/requests/api/graphql/mutations/snippets/update_spec.rb @@ -193,7 +193,6 @@ RSpec.describe 'Updating a Snippet', feature_category: :source_code_management d end it_behaves_like 'Snowplow event tracking with RedisHLL context' do - let(:feature_flag_name) { :route_hll_to_snowplow_phase2 } let(:user) { current_user } let(:property) { 'g_edit_by_snippet_ide' } let(:namespace) { project.namespace } @@ -203,8 +202,6 @@ RSpec.describe 'Updating a Snippet', feature_category: :source_code_management d let(:context) do [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: event_name).to_context] end - - let(:feature_flag_name) { :route_hll_to_snowplow_phase2 } end end end diff --git a/spec/requests/api/graphql/mutations/user_preferences/update_spec.rb b/spec/requests/api/graphql/mutations/user_preferences/update_spec.rb index 967ad75c906..65b8083c74f 100644 --- a/spec/requests/api/graphql/mutations/user_preferences/update_spec.rb +++ b/spec/requests/api/graphql/mutations/user_preferences/update_spec.rb @@ -11,7 +11,8 @@ RSpec.describe Mutations::UserPreferences::Update, feature_category: :user_profi let(:input) do { - 'issuesSort' => sort_value + 'issuesSort' => sort_value, + 'visibilityPipelineIdType' => 'IID' } end @@ -24,15 +25,20 @@ RSpec.describe Mutations::UserPreferences::Update, feature_category: :user_profi expect(response).to have_gitlab_http_status(:success) expect(mutation_response['userPreferences']['issuesSort']).to eq(sort_value) + expect(mutation_response['userPreferences']['visibilityPipelineIdType']).to eq('IID') expect(current_user.user_preference.persisted?).to eq(true) expect(current_user.user_preference.issues_sort).to eq(Types::IssueSortEnum.values[sort_value].value.to_s) + expect(current_user.user_preference.visibility_pipeline_id_type).to eq('iid') end end context 'when user has existing preference' do before do - current_user.create_user_preference!(issues_sort: Types::IssueSortEnum.values['TITLE_DESC'].value) + current_user.create_user_preference!( + issues_sort: Types::IssueSortEnum.values['TITLE_DESC'].value, + visibility_pipeline_id_type: 'id' + ) end it 'updates the existing value' do @@ -42,8 +48,10 @@ RSpec.describe Mutations::UserPreferences::Update, feature_category: :user_profi expect(response).to have_gitlab_http_status(:success) expect(mutation_response['userPreferences']['issuesSort']).to eq(sort_value) + expect(mutation_response['userPreferences']['visibilityPipelineIdType']).to eq('IID') expect(current_user.user_preference.issues_sort).to eq(Types::IssueSortEnum.values[sort_value].value.to_s) + expect(current_user.user_preference.visibility_pipeline_id_type).to eq('iid') end end end diff --git a/spec/requests/api/graphql/mutations/work_items/convert_spec.rb b/spec/requests/api/graphql/mutations/work_items/convert_spec.rb new file mode 100644 index 00000000000..97289597331 --- /dev/null +++ b/spec/requests/api/graphql/mutations/work_items/convert_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe "Converts a work item to a new type", feature_category: :team_planning do + include GraphqlHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } } + let_it_be(:new_type) { create(:work_item_type, :incident, :default) } + let_it_be(:work_item, refind: true) do + create(:work_item, :task, project: project, milestone: create(:milestone, project: project)) + end + + let(:work_item_type_id) { new_type.to_global_id.to_s } + let(:mutation) { graphql_mutation(:workItemConvert, input) } + let(:mutation_response) { graphql_mutation_response(:work_item_convert) } + let(:input) do + { + 'id' => work_item.to_global_id.to_s, + 'work_item_type_id' => work_item_type_id + } + end + + context 'when user is not allowed to update a work item' do + let(:current_user) { create(:user) } + + it_behaves_like 'a mutation that returns a top-level access error' + end + + context 'when user has permissions to convert the work item type' do + let(:current_user) { developer } + + context 'when work item type does not exist' do + let(:work_item_type_id) { "gid://gitlab/WorkItems::Type/#{non_existing_record_id}" } + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(graphql_errors).to include( + a_hash_including('message' => "Work Item type with id #{non_existing_record_id} was not found") + ) + end + end + + it 'converts the work item', :aggregate_failures do + expect do + post_graphql_mutation(mutation, current_user: current_user) + end.to change { work_item.reload.work_item_type }.to(new_type) + + expect(response).to have_gitlab_http_status(:success) + expect(work_item.reload.work_item_type.base_type).to eq('incident') + expect(mutation_response['workItem']).to include('id' => work_item.to_global_id.to_s) + expect(work_item.reload.milestone).to be_nil + end + + it_behaves_like 'has spam protection' do + let(:mutation_class) { ::Mutations::WorkItems::Convert } + end + end +end diff --git a/spec/requests/api/graphql/mutations/work_items/create_from_task_spec.rb b/spec/requests/api/graphql/mutations/work_items/create_from_task_spec.rb index 97bf060356a..6a6ad1b14fd 100644 --- a/spec/requests/api/graphql/mutations/work_items/create_from_task_spec.rb +++ b/spec/requests/api/graphql/mutations/work_items/create_from_task_spec.rb @@ -23,7 +23,7 @@ RSpec.describe "Create a work item from a task in a work item's description", fe } end - let(:mutation) { graphql_mutation(:workItemCreateFromTask, input) } + let(:mutation) { graphql_mutation(:workItemCreateFromTask, input, nil, ['productAnalyticsState']) } let(:mutation_response) { graphql_mutation_response(:work_item_create_from_task) } context 'the user is not allowed to update a work item' do @@ -45,7 +45,6 @@ RSpec.describe "Create a work item from a task in a work item's description", fe expect(response).to have_gitlab_http_status(:success) expect(work_item.description).to eq("- [ ] #{created_work_item.to_reference}+") - expect(created_work_item.issue_type).to eq('task') expect(created_work_item.work_item_type.base_type).to eq('task') expect(created_work_item.work_item_parent).to eq(work_item) expect(created_work_item).to be_confidential diff --git a/spec/requests/api/graphql/mutations/work_items/create_spec.rb b/spec/requests/api/graphql/mutations/work_items/create_spec.rb index 16f78b67b5c..fca3c84e534 100644 --- a/spec/requests/api/graphql/mutations/work_items/create_spec.rb +++ b/spec/requests/api/graphql/mutations/work_items/create_spec.rb @@ -5,52 +5,43 @@ require 'spec_helper' RSpec.describe 'Create a work item', feature_category: :team_planning do include GraphqlHelpers - let_it_be(:project) { create(:project) } - let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + let_it_be(:developer) { create(:user).tap { |user| group.add_developer(user) } } let(:input) do { 'title' => 'new title', 'description' => 'new description', 'confidential' => true, - 'workItemTypeId' => WorkItems::Type.default_by_type(:task).to_global_id.to_s + 'workItemTypeId' => WorkItems::Type.default_by_type(:task).to_gid.to_s } end - let(:mutation) { graphql_mutation(:workItemCreate, input.merge('projectPath' => project.full_path)) } - + let(:fields) { nil } let(:mutation_response) { graphql_mutation_response(:work_item_create) } + let(:current_user) { developer } - context 'the user is not allowed to create a work item' do - let(:current_user) { create(:user) } - - it_behaves_like 'a mutation that returns a top-level access error' - end - - context 'when user has permissions to create a work item' do - let(:current_user) { developer } - + RSpec.shared_examples 'creates work item' do it 'creates the work item' do expect do post_graphql_mutation(mutation, current_user: current_user) end.to change(WorkItem, :count).by(1) created_work_item = WorkItem.last - expect(response).to have_gitlab_http_status(:success) - expect(created_work_item.issue_type).to eq('task') expect(created_work_item).to be_confidential expect(created_work_item.work_item_type.base_type).to eq('task') expect(mutation_response['workItem']).to include( input.except('workItemTypeId').merge( - 'id' => created_work_item.to_global_id.to_s, + 'id' => created_work_item.to_gid.to_s, 'workItemType' => hash_including('name' => 'Task') ) ) end context 'when input is invalid' do - let(:input) { { 'title' => '', 'workItemTypeId' => WorkItems::Type.default_by_type(:task).to_global_id.to_s } } + let(:input) { { 'title' => '', 'workItemTypeId' => WorkItems::Type.default_by_type(:task).to_gid.to_s } } it 'does not create and returns validation errors' do expect do @@ -90,16 +81,14 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do FIELDS end - let(:mutation) { graphql_mutation(:workItemCreate, input.merge('projectPath' => project.full_path), fields) } - context 'when setting parent' do - let_it_be(:parent) { create(:work_item, project: project) } + let_it_be(:parent) { create(:work_item, **container_params) } let(:input) do { title: 'item1', - workItemTypeId: WorkItems::Type.default_by_type(:task).to_global_id.to_s, - hierarchyWidget: { 'parentId' => parent.to_global_id.to_s } + workItemTypeId: WorkItems::Type.default_by_type(:task).to_gid.to_s, + hierarchyWidget: { 'parentId' => parent.to_gid.to_s } } end @@ -110,14 +99,14 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do expect(widgets_response).to include( { 'children' => { 'edges' => [] }, - 'parent' => { 'id' => parent.to_global_id.to_s }, + 'parent' => { 'id' => parent.to_gid.to_s }, 'type' => 'HIERARCHY' } ) end context 'when parent work item type is invalid' do - let_it_be(:parent) { create(:work_item, :task, project: project) } + let_it_be(:parent) { create(:work_item, :task, **container_params) } it 'returns error' do post_graphql_mutation(mutation, current_user: current_user) @@ -137,6 +126,40 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do expect(graphql_errors.first['message']).to include('No object found for `parentId') end end + + context 'when adjacent is already in place' do + let_it_be(:adjacent) { create(:work_item, :task, **container_params) } + + let(:work_item) { WorkItem.last } + + let(:input) do + { + title: 'item1', + workItemTypeId: WorkItems::Type.default_by_type(:task).to_gid.to_s, + hierarchyWidget: { 'parentId' => parent.to_gid.to_s } + } + end + + before(:all) do + create(:parent_link, work_item_parent: parent, work_item: adjacent, relative_position: 0) + end + + it 'creates work item and sets the relative position to be AFTER adjacent' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + end.to change(WorkItem, :count).by(1) + + expect(response).to have_gitlab_http_status(:success) + expect(widgets_response).to include( + { + 'children' => { 'edges' => [] }, + 'parent' => { 'id' => parent.to_gid.to_s }, + 'type' => 'HIERARCHY' + } + ) + expect(work_item.parent_link.relative_position).to be > adjacent.parent_link.relative_position + end + end end context 'when unsupported widget input is sent' do @@ -144,7 +167,7 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do { 'title' => 'new title', 'description' => 'new description', - 'workItemTypeId' => WorkItems::Type.default_by_type(:test_case).to_global_id.to_s, + 'workItemTypeId' => WorkItems::Type.default_by_type(:test_case).to_gid.to_s, 'hierarchyWidget' => {} } end @@ -172,17 +195,15 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do FIELDS end - let(:mutation) { graphql_mutation(:workItemCreate, input.merge('projectPath' => project.full_path), fields) } - context 'when setting milestone on work item creation' do let_it_be(:project_milestone) { create(:milestone, project: project) } - let_it_be(:group_milestone) { create(:milestone, project: project) } + let_it_be(:group_milestone) { create(:milestone, group: group) } let(:input) do { title: 'some WI', - workItemTypeId: WorkItems::Type.default_by_type(:task).to_global_id.to_s, - milestoneWidget: { 'milestoneId' => milestone.to_global_id.to_s } + workItemTypeId: WorkItems::Type.default_by_type(:task).to_gid.to_s, + milestoneWidget: { 'milestoneId' => milestone.to_gid.to_s } } end @@ -196,13 +217,18 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do expect(widgets_response).to include( { 'type' => 'MILESTONE', - 'milestone' => { 'id' => milestone.to_global_id.to_s } + 'milestone' => { 'id' => milestone.to_gid.to_s } } ) end end context 'when assigning a project milestone' do + before do + group_work_item = container_params[:namespace].present? + skip('cannot set a project level milestone to a group level work item') if group_work_item + end + it_behaves_like "work item's milestone is set" do let(:milestone) { project_milestone } end @@ -216,4 +242,66 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do end end end + + context 'the user is not allowed to create a work item' do + let(:current_user) { create(:user) } + let(:mutation) { graphql_mutation(:workItemCreate, input.merge('projectPath' => project.full_path), fields) } + + it_behaves_like 'a mutation that returns a top-level access error' + end + + context 'when user has permissions to create a work item' do + context 'when creating work items in a project' do + context 'with projectPath' do + let_it_be(:container_params) { { project: project } } + let(:mutation) { graphql_mutation(:workItemCreate, input.merge('projectPath' => project.full_path), fields) } + + it_behaves_like 'creates work item' + end + + context 'with namespacePath' do + let_it_be(:container_params) { { project: project } } + let(:mutation) { graphql_mutation(:workItemCreate, input.merge('namespacePath' => project.full_path), fields) } + + it_behaves_like 'creates work item' + end + end + + context 'when creating work items in a group' do + let_it_be(:container_params) { { namespace: group } } + let(:mutation) { graphql_mutation(:workItemCreate, input.merge(namespacePath: group.full_path), fields) } + + it_behaves_like 'creates work item' + end + + context 'when both projectPath and namespacePath are passed' do + let_it_be(:container_params) { { project: project } } + let(:mutation) do + graphql_mutation( + :workItemCreate, + input.merge('projectPath' => project.full_path, 'namespacePath' => project.full_path), + fields + ) + end + + it_behaves_like 'a mutation that returns top-level errors', errors: [ + Mutations::WorkItems::Create::MUTUALLY_EXCLUSIVE_ARGUMENTS_ERROR + ] + end + + context 'when neither of projectPath nor namespacePath are passed' do + let_it_be(:container_params) { { project: project } } + let(:mutation) do + graphql_mutation( + :workItemCreate, + input, + fields + ) + end + + it_behaves_like 'a mutation that returns top-level errors', errors: [ + Mutations::WorkItems::Create::MUTUALLY_EXCLUSIVE_ARGUMENTS_ERROR + ] + end + end end diff --git a/spec/requests/api/graphql/mutations/work_items/export_spec.rb b/spec/requests/api/graphql/mutations/work_items/export_spec.rb new file mode 100644 index 00000000000..d5d07ea65f8 --- /dev/null +++ b/spec/requests/api/graphql/mutations/work_items/export_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Export work items', feature_category: :team_planning do + include GraphqlHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:reporter) { create(:user).tap { |user| project.add_reporter(user) } } + let_it_be(:guest) { create(:user).tap { |user| project.add_guest(user) } } + let_it_be(:work_item) { create(:work_item, project: project) } + + let(:input) { { 'projectPath' => project.full_path } } + let(:mutation) { graphql_mutation(:workItemExport, input) } + let(:mutation_response) { graphql_mutation_response(:work_item_export) } + + context 'when user is not allowed to export work items' do + let(:current_user) { guest } + + it_behaves_like 'a mutation that returns a top-level access error' + end + + context 'when import_export_work_items_csv feature flag is disabled' do + let(:current_user) { reporter } + + before do + stub_feature_flags(import_export_work_items_csv: false) + end + + it_behaves_like 'a mutation that returns top-level errors', + errors: ['`import_export_work_items_csv` feature flag is disabled.'] + end + + context 'when user has permissions to export work items' do + let(:current_user) { reporter } + let(:input) do + super().merge( + 'selectedFields' => %w[TITLE DESCRIPTION AUTHOR TYPE AUTHOR_USERNAME CREATED_AT], + 'authorUsername' => 'admin', + 'iids' => [work_item.iid.to_s], + 'state' => 'opened', + 'types' => 'TASK', + 'search' => 'any', + 'in' => 'TITLE' + ) + end + + it 'schedules export job with given arguments', :aggregate_failures do + expected_arguments = { + selected_fields: ['title', 'description', 'author', 'type', 'author username', 'created_at'], + author_username: 'admin', + iids: [work_item.iid.to_s], + state: 'opened', + issue_types: ['task'], + search: 'any', + in: ['title'] + } + + expect(IssuableExportCsvWorker) + .to receive(:perform_async).with(:work_item, current_user.id, project.id, expected_arguments) + + post_graphql_mutation(mutation, current_user: current_user) + + expect(mutation_response['message']).to eq( + 'Your CSV export request has succeeded. The result will be emailed to ' \ + "#{reporter.notification_email_or_default}." + ) + expect(mutation_response['errors']).to be_empty + end + end +end diff --git a/spec/requests/api/graphql/mutations/work_items/update_spec.rb b/spec/requests/api/graphql/mutations/work_items/update_spec.rb index ddd294e8f82..ce1c2c01faa 100644 --- a/spec/requests/api/graphql/mutations/work_items/update_spec.rb +++ b/spec/requests/api/graphql/mutations/work_items/update_spec.rb @@ -7,20 +7,21 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do let_it_be(:group) { create(:group) } let_it_be(:project) { create(:project, group: group) } + let_it_be(:author) { create(:user).tap { |user| project.add_reporter(user) } } let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } } let_it_be(:reporter) { create(:user).tap { |user| project.add_reporter(user) } } let_it_be(:guest) { create(:user).tap { |user| project.add_guest(user) } } - let_it_be(:work_item, refind: true) { create(:work_item, project: project) } + let_it_be(:work_item, refind: true) { create(:work_item, project: project, author: author) } let(:work_item_event) { 'CLOSE' } let(:input) { { 'stateEvent' => work_item_event, 'title' => 'updated title' } } let(:fields) do <<~FIELDS - workItem { - state - title - } - errors + workItem { + state + title + } + errors FIELDS end @@ -81,10 +82,10 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do context 'when updating confidentiality' do let(:fields) do <<~FIELDS - workItem { - confidential - } - errors + workItem { + confidential + } + errors FIELDS end @@ -126,18 +127,18 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do context 'with description widget input' do let(:fields) do <<~FIELDS - workItem { - title - description - state - widgets { - type - ... on WorkItemWidgetDescription { - description + workItem { + title + description + state + widgets { + type + ... on WorkItemWidgetDescription { + description + } } } - } - errors + errors FIELDS end @@ -445,31 +446,84 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do let(:widgets_response) { mutation_response['workItem']['widgets'] } let(:fields) do <<~FIELDS - workItem { - description - widgets { - type - ... on WorkItemWidgetHierarchy { - parent { - id - } - children { - edges { - node { - id + workItem { + description + widgets { + type + ... on WorkItemWidgetHierarchy { + parent { + id + } + children { + edges { + node { + id + } } } } } } - } - errors + errors FIELDS end + let_it_be(:valid_parent) { create(:work_item, project: project) } + let_it_be(:valid_child1) { create(:work_item, :task, project: project, created_at: 5.minutes.ago) } + let_it_be(:valid_child2) { create(:work_item, :task, project: project, created_at: 5.minutes.from_now) } + let(:input_base) { { parentId: valid_parent.to_gid.to_s } } + let(:child1_ref) { { adjacentWorkItemId: valid_child1.to_global_id.to_s } } + let(:child2_ref) { { adjacentWorkItemId: valid_child2.to_global_id.to_s } } + let(:relative_range) { [valid_child1, valid_child2].map(&:parent_link).map(&:relative_position) } + + let(:invalid_relative_position_error) do + WorkItems::Widgets::HierarchyService::UpdateService::INVALID_RELATIVE_POSITION_ERROR + end + + shared_examples 'updates work item parent and sets the relative position' do + it do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to change(work_item, :work_item_parent).from(nil).to(valid_parent) + + expect(response).to have_gitlab_http_status(:success) + expect(widgets_response).to include({ 'type' => 'HIERARCHY', 'children' => { 'edges' => [] }, + 'parent' => { 'id' => valid_parent.to_global_id.to_s } }) + + expect(work_item.parent_link.relative_position).to be_between(*relative_range) + end + end + + shared_examples 'sets the relative position and does not update work item parent' do + it do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to not_change(work_item, :work_item_parent) + + expect(response).to have_gitlab_http_status(:success) + expect(widgets_response).to include({ 'type' => 'HIERARCHY', 'children' => { 'edges' => [] }, + 'parent' => { 'id' => valid_parent.to_global_id.to_s } }) + + expect(work_item.parent_link.relative_position).to be_between(*relative_range) + end + end + + shared_examples 'returns "relative position is not valid" error message' do + it do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to not_change(work_item, :work_item_parent) + + expect(mutation_response['workItem']).to be_nil + expect(mutation_response['errors']).to match_array([invalid_relative_position_error]) + end + end + context 'when updating parent' do let_it_be(:work_item, reload: true) { create(:work_item, :task, project: project) } - let_it_be(:valid_parent) { create(:work_item, project: project) } let_it_be(:invalid_parent) { create(:work_item, :task, project: project) } context 'when parent work item type is invalid' do @@ -492,20 +546,15 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do context 'when parent work item has a valid type' do let(:input) { { 'hierarchyWidget' => { 'parentId' => valid_parent.to_global_id.to_s } } } - it 'sets the parent for the work item' do + it 'updates work item parent' do expect do post_graphql_mutation(mutation, current_user: current_user) work_item.reload end.to change(work_item, :work_item_parent).from(nil).to(valid_parent) expect(response).to have_gitlab_http_status(:success) - expect(widgets_response).to include( - { - 'children' => { 'edges' => [] }, - 'parent' => { 'id' => valid_parent.to_global_id.to_s }, - 'type' => 'HIERARCHY' - } - ) + expect(widgets_response).to include({ 'type' => 'HIERARCHY', 'children' => { 'edges' => [] }, + 'parent' => { 'id' => valid_parent.to_global_id.to_s } }) end context 'when a parent is already present' do @@ -522,6 +571,31 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do end.to change(work_item, :work_item_parent).from(existing_parent).to(valid_parent) end end + + context 'when updating relative position' do + before(:all) do + create(:parent_link, work_item_parent: valid_parent, work_item: valid_child1) + create(:parent_link, work_item_parent: valid_parent, work_item: valid_child2) + end + + context "when incomplete positioning arguments are given" do + let(:input) { { hierarchyWidget: input_base.merge(child1_ref) } } + + it_behaves_like 'returns "relative position is not valid" error message' + end + + context 'when moving after adjacent' do + let(:input) { { hierarchyWidget: input_base.merge(child1_ref).merge(relativePosition: 'AFTER') } } + + it_behaves_like 'updates work item parent and sets the relative position' + end + + context 'when moving before adjacent' do + let(:input) { { hierarchyWidget: input_base.merge(child2_ref).merge(relativePosition: 'BEFORE') } } + + it_behaves_like 'updates work item parent and sets the relative position' + end + end end context 'when parentId is null' do @@ -577,9 +651,37 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do end end + context 'when reordering existing child' do + let_it_be(:work_item, reload: true) { create(:work_item, :task, project: project) } + + context "when parent is already assigned" do + before(:all) do + create(:parent_link, work_item_parent: valid_parent, work_item: work_item) + create(:parent_link, work_item_parent: valid_parent, work_item: valid_child1) + create(:parent_link, work_item_parent: valid_parent, work_item: valid_child2) + end + + context "when incomplete positioning arguments are given" do + let(:input) { { hierarchyWidget: child1_ref } } + + it_behaves_like 'returns "relative position is not valid" error message' + end + + context 'when moving after adjacent' do + let(:input) { { hierarchyWidget: child1_ref.merge(relativePosition: 'AFTER') } } + + it_behaves_like 'sets the relative position and does not update work item parent' + end + + context 'when moving before adjacent' do + let(:input) { { hierarchyWidget: child2_ref.merge(relativePosition: 'BEFORE') } } + + it_behaves_like 'sets the relative position and does not update work item parent' + end + end + end + context 'when updating children' do - let_it_be(:valid_child1) { create(:work_item, :task, project: project) } - let_it_be(:valid_child2) { create(:work_item, :task, project: project) } let_it_be(:invalid_child) { create(:work_item, project: project) } let(:input) { { 'hierarchyWidget' => { 'childrenIds' => children_ids } } } @@ -639,23 +741,29 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do context 'when updating assignees' do let(:fields) do <<~FIELDS - workItem { - widgets { - type - ... on WorkItemWidgetAssignees { - assignees { - nodes { - id - username + workItem { + title + workItemType { name } + widgets { + type + ... on WorkItemWidgetAssignees { + assignees { + nodes { + id + username + } } } - } - ... on WorkItemWidgetDescription { - description + ... on WorkItemWidgetDescription { + description + } + ... on WorkItemWidgetStartAndDueDate { + startDate + dueDate + } } } - } - errors + errors FIELDS end @@ -728,6 +836,79 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do ) end end + + context 'when changing work item type' do + let_it_be(:work_item) { create(:work_item, :task, project: project) } + let(:description) { "/type Issue" } + + let(:input) { { 'descriptionWidget' => { 'description' => description } } } + + context 'with multiple commands' do + let_it_be(:work_item) { create(:work_item, :task, project: project) } + + let(:description) { "Updating work item\n/type Issue\n/due tomorrow\n/title Foo" } + + it 'updates the work item type and other attributes' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to change { work_item.work_item_type.base_type }.from('task').to('issue') + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['workItemType']['name']).to eq('Issue') + expect(mutation_response['workItem']['title']).to eq('Foo') + expect(mutation_response['workItem']['widgets']).to include( + 'type' => 'START_AND_DUE_DATE', + 'dueDate' => Date.tomorrow.strftime('%Y-%m-%d'), + 'startDate' => nil + ) + end + end + + context 'when conversion is not permitted' do + let_it_be(:issue) { create(:work_item, project: project) } + let_it_be(:link) { create(:parent_link, work_item_parent: issue, work_item: work_item) } + + let(:error_msg) { 'Work item type cannot be changed to Issue with Issue as parent type.' } + + it 'does not update the work item type' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.not_to change { work_item.work_item_type.base_type } + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['errors']).to include(error_msg) + end + end + + context 'when new type does not support a widget' do + before do + work_item.update!(start_date: Date.current, due_date: Date.tomorrow) + WorkItems::Type.default_by_type(:issue).widget_definitions + .find_by_widget_type(:start_and_due_date).update!(disabled: true) + end + + it 'updates the work item type and clear widget attributes' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to change { work_item.work_item_type.base_type }.from('task').to('issue') + .and change { work_item.start_date }.to(nil) + .and change { work_item.start_date }.to(nil) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['workItemType']['name']).to eq('Issue') + expect(mutation_response['workItem']['widgets']).to include( + { + 'type' => 'START_AND_DUE_DATE', + 'startDate' => nil, + 'dueDate' => nil + } + ) + end + end + end end context 'when the work item type does not support the assignees widget' do @@ -766,17 +947,17 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do let(:fields) do <<~FIELDS - workItem { - widgets { - type - ... on WorkItemWidgetMilestone { - milestone { - id + workItem { + widgets { + type + ... on WorkItemWidgetMilestone { + milestone { + id + } } } } - } - errors + errors FIELDS end @@ -843,18 +1024,427 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do end end + context 'when updating notifications subscription' do + let_it_be(:current_user) { reporter } + let(:input) { { 'notificationsWidget' => { 'subscribed' => desired_state } } } + + let(:fields) do + <<~FIELDS + workItem { + widgets { + type + ... on WorkItemWidgetNotifications { + subscribed + } + } + } + errors + FIELDS + end + + subject(:update_work_item) { post_graphql_mutation(mutation, current_user: current_user) } + + shared_examples 'subscription updated successfully' do + let_it_be(:subscription) do + create( + :subscription, project: project, + user: current_user, + subscribable: work_item, + subscribed: !desired_state + ) + end + + it "updates existing work item's subscription state" do + expect do + update_work_item + subscription.reload + end.to change(subscription, :subscribed).to(desired_state) + .and(change { work_item.reload.subscribed?(reporter, project) }.to(desired_state)) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'subscribed' => desired_state, + 'type' => 'NOTIFICATIONS' + } + ) + end + end + + shared_examples 'subscription update ignored' do + context 'when user is subscribed with a subscription record' do + let_it_be(:subscription) do + create( + :subscription, project: project, + user: current_user, + subscribable: work_item, + subscribed: !desired_state + ) + end + + it 'ignores the update request' do + expect do + update_work_item + subscription.reload + end.to not_change(subscription, :subscribed) + .and(not_change { work_item.subscribed?(current_user, project) }) + + expect(response).to have_gitlab_http_status(:success) + end + end + + context 'when user is subscribed by being a participant' do + let_it_be(:current_user) { author } + + it 'ignores the update request' do + expect do + update_work_item + end.to not_change(Subscription, :count) + .and(not_change { work_item.subscribed?(current_user, project) }) + + expect(response).to have_gitlab_http_status(:success) + end + end + end + + context 'when work item update fails' do + let_it_be(:desired_state) { false } + let(:input) { { 'title' => nil, 'notificationsWidget' => { 'subscribed' => desired_state } } } + + it_behaves_like 'subscription update ignored' + end + + context 'when user cannot update work item' do + let_it_be(:desired_state) { false } + + before do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?) + .with(current_user, :update_subscription, work_item).and_return(false) + end + + it_behaves_like 'subscription update ignored' + end + + context 'when user can update work item' do + context 'when subscribing to notifications' do + let_it_be(:desired_state) { true } + + it_behaves_like 'subscription updated successfully' + end + + context 'when unsubscribing from notifications' do + let_it_be(:desired_state) { false } + + it_behaves_like 'subscription updated successfully' + + context 'when user is subscribed by being a participant' do + let_it_be(:current_user) { author } + + it 'creates a subscription with desired state' do + expect { update_work_item }.to change(Subscription, :count).by(1) + .and(change { work_item.reload.subscribed?(author, project) }.to(desired_state)) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'subscribed' => desired_state, + 'type' => 'NOTIFICATIONS' + } + ) + end + end + end + end + end + + context 'when updating currentUserTodos' do + let_it_be(:current_user) { reporter } + + let(:fields) do + <<~FIELDS + workItem { + widgets { + type + ... on WorkItemWidgetCurrentUserTodos { + currentUserTodos { + nodes { + id + state + } + } + } + } + } + errors + FIELDS + end + + subject(:update_work_item) { post_graphql_mutation(mutation, current_user: current_user) } + + context 'when adding a new todo' do + let(:input) { { 'currentUserTodosWidget' => { 'action' => 'ADD' } } } + + context 'when user has access to the work item' do + it 'adds a new todo for the user on the work item' do + expect { update_work_item }.to change { current_user.todos.count }.by(1) + + created_todo = current_user.todos.last + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'type' => 'CURRENT_USER_TODOS', + 'currentUserTodos' => { + 'nodes' => [ + { 'id' => created_todo.to_global_id.to_s, 'state' => 'pending' } + ] + } + } + ) + end + end + + context 'when user has no access' do + let_it_be(:current_user) { create(:user) } + + it 'does not create a new todo' do + expect { update_work_item }.to change { Todo.count }.by(0) + + expect(response).to have_gitlab_http_status(:success) + end + end + end + + context 'when marking all todos of the work item as done' do + let_it_be(:pending_todo1) do + create(:todo, target: work_item, target_type: 'WorkItem', user: current_user, state: :pending) + end + + let_it_be(:pending_todo2) do + create(:todo, target: work_item, target_type: 'WorkItem', user: current_user, state: :pending) + end + + let(:input) { { 'currentUserTodosWidget' => { 'action' => 'MARK_AS_DONE' } } } + + context 'when user has access' do + it 'marks all todos of the user on the work item as done' do + expect { update_work_item }.to change { current_user.todos.done.count }.by(2) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'type' => 'CURRENT_USER_TODOS', + 'currentUserTodos' => { + 'nodes' => match_array([ + { 'id' => pending_todo1.to_global_id.to_s, 'state' => 'done' }, + { 'id' => pending_todo2.to_global_id.to_s, 'state' => 'done' } + ]) + } + } + ) + end + end + + context 'when user has no access' do + let_it_be(:current_user) { create(:user) } + + it 'does not mark todos as done' do + expect { update_work_item }.to change { Todo.done.count }.by(0) + + expect(response).to have_gitlab_http_status(:success) + end + end + end + + context 'when marking one todo of the work item as done' do + let_it_be(:pending_todo1) do + create(:todo, target: work_item, target_type: 'WorkItem', user: current_user, state: :pending) + end + + let_it_be(:pending_todo2) do + create(:todo, target: work_item, target_type: 'WorkItem', user: current_user, state: :pending) + end + + let(:input) do + { 'currentUserTodosWidget' => { 'action' => 'MARK_AS_DONE', todo_id: global_id_of(pending_todo1) } } + end + + context 'when user has access' do + it 'marks the todo of the work item as done' do + expect { update_work_item }.to change { current_user.todos.done.count }.by(1) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'type' => 'CURRENT_USER_TODOS', + 'currentUserTodos' => { + 'nodes' => match_array([ + { 'id' => pending_todo1.to_global_id.to_s, 'state' => 'done' }, + { 'id' => pending_todo2.to_global_id.to_s, 'state' => 'pending' } + ]) + } + } + ) + end + end + + context 'when user has no access' do + let_it_be(:current_user) { create(:user) } + + it 'does not mark the todo as done' do + expect { update_work_item }.to change { Todo.done.count }.by(0) + + expect(response).to have_gitlab_http_status(:success) + end + end + end + end + + context 'when updating awardEmoji' do + let_it_be(:current_user) { work_item.author } + let_it_be(:upvote) { create(:award_emoji, :upvote, awardable: work_item, user: current_user) } + let(:award_action) { 'ADD' } + let(:award_name) { 'star' } + let(:input) { { 'awardEmojiWidget' => { 'action' => award_action, 'name' => award_name } } } + + let(:fields) do + <<~FIELDS + workItem { + widgets { + type + ... on WorkItemWidgetAwardEmoji { + upvotes + downvotes + awardEmoji { + nodes { + name + user { id } + } + } + } + } + } + errors + FIELDS + end + + subject(:update_work_item) { post_graphql_mutation(mutation, current_user: current_user) } + + context 'when user cannot award work item' do + before do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?) + .with(current_user, :award_emoji, work_item).and_return(false) + end + + it 'ignores the update request' do + expect do + update_work_item + end.to not_change(AwardEmoji, :count) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['errors']).to be_empty + expect(graphql_errors).to be_blank + end + end + + context 'when user can award work item' do + shared_examples 'request with error' do |message| + it 'ignores update and returns an error' do + expect do + update_work_item + end.not_to change(AwardEmoji, :count) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']).to be_nil + expect(mutation_response['errors'].first).to include(message) + end + end + + shared_examples 'request that removes emoji' do + it "updates work item's award emoji" do + expect do + update_work_item + end.to change(AwardEmoji, :count).by(-1) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'upvotes' => 0, + 'downvotes' => 0, + 'awardEmoji' => { 'nodes' => [] }, + 'type' => 'AWARD_EMOJI' + } + ) + end + end + + shared_examples 'request that adds emoji' do + it "updates work item's award emoji" do + expect do + update_work_item + end.to change(AwardEmoji, :count).by(1) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'upvotes' => 1, + 'downvotes' => 0, + 'awardEmoji' => { 'nodes' => [ + { 'name' => 'thumbsup', 'user' => { 'id' => current_user.to_gid.to_s } }, + { 'name' => award_name, 'user' => { 'id' => current_user.to_gid.to_s } } + ] }, + 'type' => 'AWARD_EMOJI' + } + ) + end + end + + context 'when adding award emoji' do + it_behaves_like 'request that adds emoji' + + context 'when the emoji name is not valid' do + let(:award_name) { 'xxqq' } + + it_behaves_like 'request with error', 'Name is not a valid emoji name' + end + end + + context 'when removing award emoji' do + let(:award_action) { 'REMOVE' } + + context 'when emoji was awarded by current user' do + let(:award_name) { 'thumbsup' } + + it_behaves_like 'request that removes emoji' + end + + context 'when emoji was awarded by a different user' do + let(:award_name) { 'thumbsdown' } + + before do + create(:award_emoji, :downvote, awardable: work_item) + end + + it_behaves_like 'request with error', + 'User has not awarded emoji of type thumbsdown on the awardable' + end + end + end + end + context 'when unsupported widget input is sent' do - let_it_be(:test_case) { create(:work_item_type, :default, :test_case) } - let_it_be(:work_item) { create(:work_item, work_item_type: test_case, project: project) } + let_it_be(:work_item) { create(:work_item, :test_case, project: project) } let(:input) do { - 'hierarchyWidget' => {} + 'assigneesWidget' => { 'assigneeIds' => [developer.to_gid.to_s] } } end it_behaves_like 'a mutation that returns top-level errors', - errors: ["Following widget keys are not supported by Test Case type: [:hierarchy_widget]"] + errors: ["Following widget keys are not supported by Test Case type: [:assignees_widget]"] end end end diff --git a/spec/requests/api/graphql/mutations/work_items/update_task_spec.rb b/spec/requests/api/graphql/mutations/work_items/update_task_spec.rb index 999c685ac6a..717de983871 100644 --- a/spec/requests/api/graphql/mutations/work_items/update_task_spec.rb +++ b/spec/requests/api/graphql/mutations/work_items/update_task_spec.rb @@ -18,7 +18,7 @@ RSpec.describe 'Update a work item task', feature_category: :team_planning do let(:task_params) { { 'title' => 'UPDATED' } } let(:task_input) { { 'id' => task.to_global_id.to_s }.merge(task_params) } let(:input) { { 'id' => work_item.to_global_id.to_s, 'taskData' => task_input } } - let(:mutation) { graphql_mutation(:workItemUpdateTask, input) } + let(:mutation) { graphql_mutation(:workItemUpdateTask, input, nil, ['productAnalyticsState']) } let(:mutation_response) { graphql_mutation_response(:work_item_update_task) } context 'the user is not allowed to read a work item' do |