diff --git a/app/graphql/mutations/packages/protection/rule/delete.rb b/app/graphql/mutations/packages/protection/rule/delete.rb new file mode 100644 index 0000000000000000000000000000000000000000..bd0159d3c23c8f5864f39d2e5117e6c8b9b25c8d --- /dev/null +++ b/app/graphql/mutations/packages/protection/rule/delete.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Mutations + module Packages + module Protection + module Rule + class Delete < ::Mutations::BaseMutation + graphql_name 'DeletePackagesProtectionRule' + description 'Deletes a protection rule for packages. ' \ + 'Available only when feature flag `packages_protected_packages` is enabled.' + + authorize :admin_package + + argument :id, + ::Types::GlobalIDType[::Packages::Protection::Rule], + required: true, + description: 'Global ID of the package protection rule to delete.' + + field :package_protection_rule, + Types::Packages::Protection::RuleType, + null: true, + description: 'Packages protection rule that was deleted successfully.' + + def resolve(id:, **_kwargs) + if Feature.disabled?(:packages_protected_packages) + raise_resource_not_available_error!("'packages_protected_packages' feature flag is disabled") + end + + package_protection_rule = authorized_find!(id: id) + + response = ::Packages::Protection::DeleteRuleService.new(package_protection_rule, + current_user: current_user).execute + + { package_protection_rule: response.payload[:package_protection_rule], errors: response.errors } + end + end + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 9c292404aed81caf831d057f8d99e8b1b2b31dc2..475066e727913831265632c5512be7279073fe21 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -173,6 +173,7 @@ class MutationType < BaseObject extensions: [::Gitlab::Graphql::Limit::FieldCallCount => { limit: 1 }] mount_mutation Mutations::Packages::DestroyFile mount_mutation Mutations::Packages::Protection::Rule::Create, alpha: { milestone: '16.5' } + mount_mutation Mutations::Packages::Protection::Rule::Delete, alpha: { milestone: '16.6' } mount_mutation Mutations::Packages::DestroyFiles mount_mutation Mutations::Packages::Cleanup::Policy::Update mount_mutation Mutations::Echo diff --git a/app/services/packages/protection/delete_rule_service.rb b/app/services/packages/protection/delete_rule_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..a1fa111b57bee180057da98f38b21ba0215a8aad --- /dev/null +++ b/app/services/packages/protection/delete_rule_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Packages + module Protection + class DeleteRuleService + include Gitlab::Allowable + + def initialize(package_protection_rule, current_user:) + if package_protection_rule.blank? || current_user.blank? + raise ArgumentError, + 'package_protection_rule and current_user must be set' + end + + @package_protection_rule = package_protection_rule + @current_user = current_user + end + + def execute + unless can?(current_user, :admin_package, package_protection_rule.project) + error_message = _('Unauthorized to delete a package protection rule') + return service_response_error(message: error_message) + end + + deleted_package_protection_rule = package_protection_rule.destroy! + + ServiceResponse.success(payload: { package_protection_rule: deleted_package_protection_rule }) + rescue StandardError => e + service_response_error(message: e.message) + end + + private + + attr_reader :package_protection_rule, :current_user + + def service_response_error(message:) + ServiceResponse.error( + message: message, + payload: { package_protection_rule: nil } + ) + end + end + end +end diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 24ce86eb5c9135083964ccc20f93d0c3d243652e..a013d4181067c853467d854632e5be15531ad1a5 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -3033,6 +3033,31 @@ Input type: `DeleteAnnotationInput` | `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +### `Mutation.deletePackagesProtectionRule` + +Deletes a protection rule for packages. Available only when feature flag `packages_protected_packages` is enabled. + +WARNING: +**Introduced** in 16.6. +This feature is an Experiment. It can be changed or removed at any time. + +Input type: `DeletePackagesProtectionRuleInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `id` | [`PackagesProtectionRuleID!`](#packagesprotectionruleid) | Global ID of the package protection rule to delete. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +| `packageProtectionRule` | [`PackagesProtectionRule`](#packagesprotectionrule) | Packages protection rule that was deleted successfully. | + ### `Mutation.designManagementDelete` Input type: `DesignManagementDeleteInput` @@ -30751,6 +30776,12 @@ A `PackagesPackageID` is a global ID. It is encoded as a string. An example `PackagesPackageID` is: `"gid://gitlab/Packages::Package/1"`. +### `PackagesProtectionRuleID` + +A `PackagesProtectionRuleID` is a global ID. It is encoded as a string. + +An example `PackagesProtectionRuleID` is: `"gid://gitlab/Packages::Protection::Rule/1"`. + ### `PackagesPypiMetadatumID` A `PackagesPypiMetadatumID` is a global ID. It is encoded as a string. diff --git a/locale/gitlab.pot b/locale/gitlab.pot index a928d73e9a2b45405eb4b736b8a8187848ed0f7a..e123c9063e8edaddab8da1212f66c33fa437fe33 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -50701,6 +50701,9 @@ msgstr "" msgid "Unauthorized to create an environment" msgstr "" +msgid "Unauthorized to delete a package protection rule" +msgstr "" + msgid "Unauthorized to update the environment" msgstr "" diff --git a/spec/requests/api/graphql/mutations/packages/protection/rule/delete_spec.rb b/spec/requests/api/graphql/mutations/packages/protection/rule/delete_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d5b602d466430ce56d5c48dcbe29feb0de0b7bb4 --- /dev/null +++ b/spec/requests/api/graphql/mutations/packages/protection/rule/delete_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Deleting a package protection rule', :aggregate_failures, feature_category: :package_registry do + include GraphqlHelpers + + let_it_be(:project) { create(:project, :repository) } + let_it_be_with_refind(:package_protection_rule) { create(:package_protection_rule, project: project) } + let_it_be(:current_user) { create(:user, maintainer_projects: [project]) } + + let(:mutation) { graphql_mutation(:delete_packages_protection_rule, input) } + let(:mutation_response) { graphql_mutation_response(:delete_packages_protection_rule) } + let(:input) { { id: package_protection_rule.to_global_id } } + + subject { post_graphql_mutation(mutation, current_user: current_user) } + + shared_examples 'an erroneous reponse' do + it { subject.tap { expect(mutation_response).to be_blank } } + it { expect { subject }.not_to change { ::Packages::Protection::Rule.count } } + end + + it_behaves_like 'a working GraphQL mutation' + + it 'responds with deleted package protection rule' do + subject + + expect(mutation_response).to include( + 'packageProtectionRule' => { + 'packageNamePattern' => package_protection_rule.package_name_pattern, + 'packageType' => package_protection_rule.package_type.upcase, + 'pushProtectedUpToAccessLevel' => package_protection_rule.push_protected_up_to_access_level.upcase + }, 'errors' => be_blank + ) + end + + it { is_expected.tap { expect_graphql_errors_to_be_empty } } + it { expect { subject }.to change { ::Packages::Protection::Rule.count }.from(1).to(0) } + + context 'with existing package protection rule belonging to other project' do + let_it_be(:package_protection_rule) do + create(:package_protection_rule, package_name_pattern: 'protection_rule_other_project') + end + + it_behaves_like 'an erroneous reponse' + + it { subject.tap { expect_graphql_errors_to_include(/you don't have permission to perform this action/) } } + end + + context 'with deleted package protection rule' do + let!(:package_protection_rule) do + create(:package_protection_rule, project: project, package_name_pattern: 'protection_rule_deleted').destroy! + end + + it_behaves_like 'an erroneous reponse' + + it { subject.tap { expect_graphql_errors_to_include(/you don't have permission to perform this action/) } } + end + + context 'when current_user does not have permission' do + let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } } + let_it_be(:reporter) { create(:user).tap { |u| project.add_reporter(u) } } + let_it_be(:guest) { create(:user).tap { |u| project.add_guest(u) } } + let_it_be(:anonymous) { create(:user) } + + where(:current_user) do + [ref(:developer), ref(:reporter), ref(:guest), ref(:anonymous)] + end + + with_them do + it_behaves_like 'an erroneous reponse' + + it { subject.tap { expect_graphql_errors_to_include(/you don't have permission to perform this action/) } } + end + end + + context "when feature flag ':packages_protected_packages' disabled" do + before do + stub_feature_flags(packages_protected_packages: false) + end + + it_behaves_like 'an erroneous reponse' + + it { subject.tap { expect_graphql_errors_to_include(/'packages_protected_packages' feature flag is disabled/) } } + end +end diff --git a/spec/services/packages/protection/delete_rule_service_spec.rb b/spec/services/packages/protection/delete_rule_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d64609d4df1acd1aa7806b069b67b8c54d084664 --- /dev/null +++ b/spec/services/packages/protection/delete_rule_service_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::Protection::DeleteRuleService, '#execute', feature_category: :package_registry do + let_it_be(:project) { create(:project) } + let_it_be(:current_user) { create(:user, maintainer_projects: [project]) } + let_it_be_with_refind(:package_protection_rule) { create(:package_protection_rule, project: project) } + + subject { described_class.new(package_protection_rule, current_user: current_user).execute } + + shared_examples 'a successful service response' do + it { is_expected.to be_success } + + it { + is_expected.to have_attributes( + errors: be_blank, + message: be_blank, + payload: { package_protection_rule: package_protection_rule } + ) + } + + it { subject.tap { expect { package_protection_rule.reload }.to raise_error ActiveRecord::RecordNotFound } } + end + + shared_examples 'an erroneous service response' do + it { is_expected.to be_error } + it { is_expected.to have_attributes(message: be_present, payload: { package_protection_rule: be_blank }) } + + it do + expect { subject }.not_to change { Packages::Protection::Rule.count } + + expect { package_protection_rule.reload }.not_to raise_error + end + end + + it_behaves_like 'a successful service response' + + it 'deletes the package protection rule in the database' do + expect { subject } + .to change { project.reload.package_protection_rules }.from([package_protection_rule]).to([]) + .and change { ::Packages::Protection::Rule.count }.from(1).to(0) + end + + context 'with deleted package protection rule' do + let!(:package_protection_rule) do + create(:package_protection_rule, project: project, package_name_pattern: 'protection_rule_deleted').destroy! + end + + it_behaves_like 'a successful service response' + end + + context 'when error occurs during delete operation' do + before do + allow(package_protection_rule).to receive(:destroy!).and_raise(StandardError.new('Some error')) + end + + it_behaves_like 'an erroneous service response' + + it { is_expected.to have_attributes message: /Some error/ } + end + + context 'when current_user does not have permission' do + let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } } + let_it_be(:reporter) { create(:user).tap { |u| project.add_reporter(u) } } + let_it_be(:guest) { create(:user).tap { |u| project.add_guest(u) } } + let_it_be(:anonymous) { create(:user) } + + where(:current_user) do + [ref(:developer), ref(:reporter), ref(:guest), ref(:anonymous)] + end + + with_them do + it_behaves_like 'an erroneous service response' + + it { is_expected.to have_attributes message: /Unauthorized to delete a package protection rule/ } + end + end + + context 'without package protection rule' do + let(:package_protection_rule) { nil } + + it { expect { subject }.to raise_error(ArgumentError) } + end + + context 'without current_user' do + let(:current_user) { nil } + let(:package_protection_rule) { build_stubbed(:package_protection_rule, project: project) } + + it { expect { subject }.to raise_error(ArgumentError) } + end +end