diff --git a/app/graphql/mutations/packages/protection/rule/create.rb b/app/graphql/mutations/packages/protection/rule/create.rb
new file mode 100644
index 0000000000000000000000000000000000000000..2b8b18c5b3004317277563fb841de8523745e02c
--- /dev/null
+++ b/app/graphql/mutations/packages/protection/rule/create.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Packages
+ module Protection
+ module Rule
+ class Create < ::Mutations::BaseMutation
+ graphql_name 'CreatePackagesProtectionRule'
+ description 'Creates a protection rule to restrict access to project packages. ' \
+ 'Available only when feature flag `packages_protected_packages` is enabled.'
+
+ include FindsProject
+
+ authorize :admin_package
+
+ argument :project_path,
+ GraphQL::Types::ID,
+ required: true,
+ description: 'Full path of the project where a protection rule is located.'
+
+ argument :package_name_pattern,
+ GraphQL::Types::String,
+ required: true,
+ description:
+ 'Package name protected by the protection rule. For example `@my-scope/my-package-*`. ' \
+ 'Wildcard character `*` allowed.'
+
+ argument :package_type,
+ Types::Packages::Protection::RulePackageTypeEnum,
+ required: true,
+ description: 'Package type protected by the protection rule. For example `NPM`.'
+
+ argument :push_protected_up_to_access_level,
+ Types::Packages::Protection::RuleAccessLevelEnum,
+ required: true,
+ description:
+ 'Max GitLab access level unable to push a package. For example `DEVELOPER`, `MAINTAINER`, `OWNER`.'
+
+ field :package_protection_rule,
+ Types::Packages::Protection::RuleType,
+ null: true,
+ description: 'Packages protection rule after mutation.'
+
+ def resolve(project_path:, **kwargs)
+ project = authorized_find!(project_path)
+
+ if Feature.disabled?(:packages_protected_packages, project)
+ raise_resource_not_available_error!("'packages_protected_packages' feature flag is disabled")
+ end
+
+ response = ::Packages::Protection::CreateRuleService.new(project, current_user, kwargs).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 9d74ca1342410fdda6197d0e68bc43d1d6383ee5..3af7140aed30d2aa85cd9fe437c4e365fca73e1e 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -170,6 +170,7 @@ class MutationType < BaseObject
mount_mutation Mutations::Packages::BulkDestroy,
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::DestroyFiles
mount_mutation Mutations::Packages::Cleanup::Policy::Update
mount_mutation Mutations::Echo
diff --git a/app/graphql/types/packages/protection/rule_access_level_enum.rb b/app/graphql/types/packages/protection/rule_access_level_enum.rb
new file mode 100644
index 0000000000000000000000000000000000000000..fbc19847bcc6a6860a686c14fb4dad2d8fb2ce18
--- /dev/null
+++ b/app/graphql/types/packages/protection/rule_access_level_enum.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Types
+ module Packages
+ module Protection
+ class RuleAccessLevelEnum < BaseEnum
+ graphql_name 'PackagesProtectionRuleAccessLevel'
+ description 'Access level of a package protection rule resource'
+
+ value 'DEVELOPER', value: Gitlab::Access::DEVELOPER, description: 'Developer access.'
+ value 'MAINTAINER', value: Gitlab::Access::MAINTAINER, description: 'Maintainer access.'
+ value 'OWNER', value: Gitlab::Access::OWNER, description: 'Owner access.'
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/packages/protection/rule_package_type_enum.rb b/app/graphql/types/packages/protection/rule_package_type_enum.rb
new file mode 100644
index 0000000000000000000000000000000000000000..28e9df76adc2c8058a69fba8255ca526c2ae465e
--- /dev/null
+++ b/app/graphql/types/packages/protection/rule_package_type_enum.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Types
+ module Packages
+ module Protection
+ class RulePackageTypeEnum < BaseEnum
+ graphql_name 'PackagesProtectionRulePackageType'
+ description 'Package type of a package protection rule resource'
+
+ ::Packages::Protection::Rule.package_types.each_key do |package_type|
+ value package_type.upcase, value: package_type,
+ description: "Packages of the #{package_type} format"
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/packages/protection/rule_type.rb b/app/graphql/types/packages/protection/rule_type.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1e969d39ce2903d08c66427ac7e2a80d72556c82
--- /dev/null
+++ b/app/graphql/types/packages/protection/rule_type.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Types
+ module Packages
+ module Protection
+ class RuleType < ::Types::BaseObject
+ graphql_name 'PackagesProtectionRule'
+ description 'A packages protection rule designed to protect packages ' \
+ 'from being pushed by users with a certain access level.'
+
+ authorize :admin_package
+
+ field :package_name_pattern,
+ GraphQL::Types::String,
+ null: false,
+ description:
+ 'Package name protected by the protection rule. For example `@my-scope/my-package-*`. ' \
+ 'Wildcard character `*` allowed.'
+
+ field :package_type,
+ Types::Packages::Protection::RulePackageTypeEnum,
+ null: false,
+ description: 'Package type protected by the protection rule. For example `NPM`.'
+
+ field :push_protected_up_to_access_level,
+ Types::Packages::Protection::RuleAccessLevelEnum,
+ null: false,
+ description:
+ 'Max GitLab access level unable to push a package. For example `DEVELOPER`, `MAINTAINER`, `OWNER`.'
+ end
+ end
+ end
+end
diff --git a/app/services/packages/protection/create_rule_service.rb b/app/services/packages/protection/create_rule_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f0bd7f3d90051de853e7e4520c683501b15e5920
--- /dev/null
+++ b/app/services/packages/protection/create_rule_service.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Packages
+ module Protection
+ class CreateRuleService < BaseService
+ ALLOWED_ATTRIBUTES = %i[
+ package_name_pattern
+ package_type
+ push_protected_up_to_access_level
+ ].freeze
+
+ def execute
+ unless can?(current_user, :admin_package, project)
+ error_message = _('Unauthorized to create a package protection rule')
+ return service_response_error(message: error_message)
+ end
+
+ package_protection_rule = project.package_protection_rules.create(params.slice(*ALLOWED_ATTRIBUTES))
+
+ unless package_protection_rule.persisted?
+ return service_response_error(message: package_protection_rule.errors.full_messages)
+ end
+
+ ServiceResponse.success(payload: { package_protection_rule: package_protection_rule })
+ rescue StandardError => e
+ service_response_error(message: e.message)
+ end
+
+ private
+
+ def service_response_error(message:)
+ ServiceResponse.error(
+ message: message,
+ payload: { package_protection_rule: nil }
+ )
+ end
+ end
+ end
+end
diff --git a/config/feature_flags/development/packages_protected_packages.yml b/config/feature_flags/development/packages_protected_packages.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6e159248e0150204e77fa924c67d68fcddf723eb
--- /dev/null
+++ b/config/feature_flags/development/packages_protected_packages.yml
@@ -0,0 +1,8 @@
+---
+name: packages_protected_packages
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/125915
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/416395
+milestone: '16.5'
+type: development
+group: group::package registry
+default_enabled: false
\ No newline at end of file
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index b4f57847408be275e09e5eb0f7bb8194968ad2c8..d35d5f3c6c4df9f3b7f61d93fc8d7f04f5a757d5 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -2392,6 +2392,34 @@ Input type: `CreateNoteInput`
| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| `note` | [`Note`](#note) | Note after mutation. |
+### `Mutation.createPackagesProtectionRule`
+
+Creates a protection rule to restrict access to project packages. Available only when feature flag `packages_protected_packages` is enabled.
+
+WARNING:
+**Introduced** in 16.5.
+This feature is an Experiment. It can be changed or removed at any time.
+
+Input type: `CreatePackagesProtectionRuleInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `packageNamePattern` | [`String!`](#string) | Package name protected by the protection rule. For example `@my-scope/my-package-*`. Wildcard character `*` allowed. |
+| `packageType` | [`PackagesProtectionRulePackageType!`](#packagesprotectionrulepackagetype) | Package type protected by the protection rule. For example `NPM`. |
+| `projectPath` | [`ID!`](#id) | Full path of the project where a protection rule is located. |
+| `pushProtectedUpToAccessLevel` | [`PackagesProtectionRuleAccessLevel!`](#packagesprotectionruleaccesslevel) | Max GitLab access level unable to push a package. For example `DEVELOPER`, `MAINTAINER`, `OWNER`. |
+
+#### 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 after mutation. |
+
### `Mutation.createRequirement`
Input type: `CreateRequirementInput`
@@ -21533,6 +21561,18 @@ A packages cleanup policy designed to keep only packages and packages assets tha
| `keepNDuplicatedPackageFiles` | [`PackagesCleanupKeepDuplicatedPackageFilesEnum!`](#packagescleanupkeepduplicatedpackagefilesenum) | Number of duplicated package files to retain. |
| `nextRunAt` | [`Time`](#time) | Next time that this packages cleanup policy will be executed. |
+### `PackagesProtectionRule`
+
+A packages protection rule designed to protect packages from being pushed by users with a certain access level.
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `packageNamePattern` | [`String!`](#string) | Package name protected by the protection rule. For example `@my-scope/my-package-*`. Wildcard character `*` allowed. |
+| `packageType` | [`PackagesProtectionRulePackageType!`](#packagesprotectionrulepackagetype) | Package type protected by the protection rule. For example `NPM`. |
+| `pushProtectedUpToAccessLevel` | [`PackagesProtectionRuleAccessLevel!`](#packagesprotectionruleaccesslevel) | Max GitLab access level unable to push a package. For example `DEVELOPER`, `MAINTAINER`, `OWNER`. |
+
### `PageInfo`
Information about pagination in a connection.
@@ -28532,6 +28572,24 @@ Values for sorting package.
| `THIRTY_PACKAGE_FILES` | Value to keep 30 package files. |
| `TWENTY_PACKAGE_FILES` | Value to keep 20 package files. |
+### `PackagesProtectionRuleAccessLevel`
+
+Access level of a package protection rule resource.
+
+| Value | Description |
+| ----- | ----------- |
+| `DEVELOPER` | Developer access. |
+| `MAINTAINER` | Maintainer access. |
+| `OWNER` | Owner access. |
+
+### `PackagesProtectionRulePackageType`
+
+Package type of a package protection rule resource.
+
+| Value | Description |
+| ----- | ----------- |
+| `NPM` | Packages of the npm format. |
+
### `PipelineConfigSourceEnum`
| Value | Description |
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 283d28b87713324a9ea60249a467fcd864d3368e..96d652ddf4cad8f65aee2c9ed06c6b23fbda7f78 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -50404,6 +50404,9 @@ msgstr ""
msgid "Unauthorized to access the cluster agent in this project"
msgstr ""
+msgid "Unauthorized to create a package protection rule"
+msgstr ""
+
msgid "Unauthorized to create an environment"
msgstr ""
diff --git a/spec/graphql/types/packages/protection/rule_access_level_enum_spec.rb b/spec/graphql/types/packages/protection/rule_access_level_enum_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..421b5fb0f399d714d64717d197fd74e99a9709d6
--- /dev/null
+++ b/spec/graphql/types/packages/protection/rule_access_level_enum_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['PackagesProtectionRuleAccessLevel'], feature_category: :package_registry do
+ it 'exposes all options' do
+ expect(described_class.values.keys).to match_array(%w[DEVELOPER MAINTAINER OWNER])
+ end
+end
diff --git a/spec/graphql/types/packages/protection/rule_package_type_enum_spec.rb b/spec/graphql/types/packages/protection/rule_package_type_enum_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..b0d9772f285c57cc9a15b17afca67fabcaaffc6f
--- /dev/null
+++ b/spec/graphql/types/packages/protection/rule_package_type_enum_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['PackagesProtectionRulePackageType'], feature_category: :package_registry do
+ it 'exposes all options' do
+ expect(described_class.values.keys).to contain_exactly('NPM')
+ end
+end
diff --git a/spec/graphql/types/packages/protection/rule_type_spec.rb b/spec/graphql/types/packages/protection/rule_type_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..a4a458d35682b978d04834d1bd9cb320182d4517
--- /dev/null
+++ b/spec/graphql/types/packages/protection/rule_type_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['PackagesProtectionRule'], feature_category: :package_registry do
+ specify { expect(described_class.graphql_name).to eq('PackagesProtectionRule') }
+
+ specify { expect(described_class.description).to be_present }
+
+ specify { expect(described_class).to require_graphql_authorizations(:admin_package) }
+
+ describe 'package_name_pattern' do
+ subject { described_class.fields['packageNamePattern'] }
+
+ it { is_expected.to have_non_null_graphql_type(GraphQL::Types::String) }
+ end
+
+ describe 'package_type' do
+ subject { described_class.fields['packageType'] }
+
+ it { is_expected.to have_non_null_graphql_type(Types::Packages::Protection::RulePackageTypeEnum) }
+ end
+
+ describe 'push_protected_up_to_access_level' do
+ subject { described_class.fields['pushProtectedUpToAccessLevel'] }
+
+ it { is_expected.to have_non_null_graphql_type(Types::Packages::Protection::RuleAccessLevelEnum) }
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/packages/protection/rule/create_spec.rb b/spec/requests/api/graphql/mutations/packages/protection/rule/create_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..b0c8526fa1cd488398f0a726b80e84c0611dc381
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/packages/protection/rule/create_spec.rb
@@ -0,0 +1,165 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Creating the packages protection rule', :aggregate_failures, feature_category: :package_registry do
+ include GraphqlHelpers
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user, maintainer_projects: [project]) }
+
+ let(:package_protection_rule_attributes) { build_stubbed(:package_protection_rule, project: project) }
+
+ let(:kwargs) do
+ {
+ project_path: project.full_path,
+ package_name_pattern: package_protection_rule_attributes.package_name_pattern,
+ package_type: "NPM",
+ push_protected_up_to_access_level: "MAINTAINER"
+ }
+ end
+
+ let(:mutation) do
+ graphql_mutation(:create_packages_protection_rule, kwargs,
+ <<~QUERY
+ clientMutationId
+ errors
+ QUERY
+ )
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:create_packages_protection_rule) }
+
+ describe 'post graphql mutation' do
+ subject { post_graphql_mutation(mutation, current_user: user) }
+
+ context 'without existing packages protection rule' do
+ it 'returns without error' do
+ subject
+
+ expect_graphql_errors_to_be_empty
+ end
+
+ it 'returns the created packages protection rule' do
+ expect { subject }.to change { ::Packages::Protection::Rule.count }.by(1)
+
+ expect_graphql_errors_to_be_empty
+ expect(Packages::Protection::Rule.where(project: project).count).to eq 1
+
+ expect(Packages::Protection::Rule.where(project: project,
+ package_name_pattern: kwargs[:package_name_pattern])).to exist
+ end
+
+ context 'when invalid fields are given' do
+ let(:kwargs) do
+ {
+ project_path: project.full_path,
+ package_name_pattern: '',
+ package_type: 'UNKNOWN_PACKAGE_TYPE',
+ push_protected_up_to_access_level: 'UNKNOWN_ACCESS_LEVEL'
+ }
+ end
+
+ it 'returns error about required argument' do
+ subject
+
+ expect_graphql_errors_to_include(/was provided invalid value for packageType/)
+ expect_graphql_errors_to_include(/pushProtectedUpToAccessLevel/)
+ end
+ end
+ end
+
+ context 'when 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(:user) do
+ [ref(:developer), ref(:reporter), ref(:guest), ref(:anonymous)]
+ end
+
+ with_them do
+ it 'returns an error' do
+ expect { subject }.not_to change { ::Packages::Protection::Rule.count }
+
+ expect_graphql_errors_to_include(/you don't have permission to perform this action/)
+ end
+ end
+ end
+
+ context 'with existing packages protection rule' do
+ let_it_be(:existing_package_protection_rule) do
+ create(:package_protection_rule, project: project, push_protected_up_to_access_level: Gitlab::Access::DEVELOPER)
+ end
+
+ context 'when package name pattern is slightly different' do
+ let(:kwargs) do
+ {
+ project_path: project.full_path,
+ # The field `package_name_pattern` is unique; this is why we change the value in a minimum way
+ package_name_pattern: "#{existing_package_protection_rule.package_name_pattern}-unique",
+ package_type: "NPM",
+ push_protected_up_to_access_level: "DEVELOPER"
+ }
+ end
+
+ it 'returns the created packages protection rule' do
+ expect { subject }.to change { ::Packages::Protection::Rule.count }.by(1)
+
+ expect(Packages::Protection::Rule.where(project: project).count).to eq 2
+ expect(Packages::Protection::Rule.where(project: project,
+ package_name_pattern: kwargs[:package_name_pattern])).to exist
+ end
+
+ it 'returns without error' do
+ subject
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
+
+ context 'when field `package_name_pattern` is taken' do
+ let(:kwargs) do
+ {
+ project_path: project.full_path,
+ package_name_pattern: existing_package_protection_rule.package_name_pattern,
+ package_type: 'NPM',
+ push_protected_up_to_access_level: 'MAINTAINER'
+ }
+ end
+
+ it 'returns without error' do
+ subject
+
+ expect(mutation_response).to include 'errors' => ['Package name pattern has already been taken']
+ end
+
+ it 'does not create new package protection rules' do
+ expect { subject }.to change { Packages::Protection::Rule.count }.by(0)
+
+ expect(Packages::Protection::Rule.where(project: project,
+ package_name_pattern: kwargs[:package_name_pattern],
+ push_protected_up_to_access_level: Gitlab::Access::MAINTAINER)).not_to exist
+ end
+ end
+ end
+
+ context "when feature flag ':packages_protected_packages' disabled" do
+ before do
+ stub_feature_flags(packages_protected_packages: false)
+ end
+
+ it 'does not create any package protection rules' do
+ expect { subject }.to change { Packages::Protection::Rule.count }.by(0)
+
+ expect(Packages::Protection::Rule.where(project: project)).not_to exist
+ end
+
+ it 'returns error of disabled feature flag' do
+ subject.tap { expect_graphql_errors_to_include(/'packages_protected_packages' feature flag is disabled/) }
+ end
+ end
+ end
+end
diff --git a/spec/services/packages/protection/create_rule_service_spec.rb b/spec/services/packages/protection/create_rule_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..cd9cb74c29bb0a11e7e6655a0406fbb55a24a350
--- /dev/null
+++ b/spec/services/packages/protection/create_rule_service_spec.rb
@@ -0,0 +1,139 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Packages::Protection::CreateRuleService, '#execute', feature_category: :environment_management do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:maintainer) { create(:user).tap { |u| project.add_maintainer(u) } }
+
+ let(:service) { described_class.new(project, current_user, params) }
+ let(:current_user) { maintainer }
+ let(:params) { attributes_for(:package_protection_rule) }
+
+ subject { service.execute }
+
+ shared_examples 'a successful service response' do
+ let(:package_protection_rule_count_expected) { 1 }
+ it { is_expected.to be_success }
+
+ it do
+ is_expected.to have_attributes(
+ payload: include(
+ package_protection_rule: be_a(Packages::Protection::Rule)
+ )
+ )
+ end
+
+ it { expect(subject.payload).to include(package_protection_rule: be_a(Packages::Protection::Rule)) }
+
+ it do
+ expect { subject }.to change { Packages::Protection::Rule.count }.by(1)
+
+ expect(Packages::Protection::Rule.where(project: project).count).to eq package_protection_rule_count_expected
+ expect(Packages::Protection::Rule.where(project: project,
+ package_name_pattern: params[:package_name_pattern])).to exist
+ end
+ end
+
+ shared_examples 'an erroneous service response' do
+ let(:package_protection_rule_count_expected) { 0 }
+ it { is_expected.to be_error }
+ it { is_expected.to have_attributes(payload: include(package_protection_rule: nil)) }
+
+ it do
+ expect { subject }.to change { Packages::Protection::Rule.count }.by(0)
+
+ expect(Packages::Protection::Rule.where(project: project).count).to eq package_protection_rule_count_expected
+ expect(Packages::Protection::Rule.where(project: project,
+ package_name_pattern: params[:package_name_pattern])).not_to exist
+ end
+ end
+
+ context 'without existing PackageProtectionRules' do
+ context 'when fields are valid' do
+ it_behaves_like 'a successful service response'
+ end
+
+ context 'when fields are invalid' do
+ let(:params) do
+ {
+ package_name_pattern: '',
+ package_type: 'unknown_package_type',
+ push_protected_up_to_access_level: 1000
+ }
+ end
+
+ it_behaves_like 'an erroneous service response'
+ end
+ end
+
+ context 'with existing PackageProtectionRule' do
+ let_it_be(:existing_package_protection_rule) { create(:package_protection_rule, project: project) }
+
+ context 'when package name pattern is slightly different' do
+ let(:params) do
+ attributes_for(
+ :package_protection_rule,
+ # The field `package_name_pattern` is unique; this is why we change the value in a minimum way
+ package_name_pattern: "#{existing_package_protection_rule.package_name_pattern}-unique",
+ package_type: existing_package_protection_rule.package_type,
+ push_protected_up_to_access_level: existing_package_protection_rule.push_protected_up_to_access_level
+ )
+ end
+
+ it_behaves_like 'a successful service response' do
+ let(:package_protection_rule_count_expected) { 2 }
+ end
+ end
+
+ context 'when field `package_name_pattern` is taken' do
+ let(:params) do
+ attributes_for(
+ :package_protection_rule,
+ package_name_pattern: existing_package_protection_rule.package_name_pattern,
+ package_type: existing_package_protection_rule.package_type,
+ push_protected_up_to_access_level: existing_package_protection_rule.push_protected_up_to_access_level
+ )
+ end
+
+ it { is_expected.to be_error }
+
+ it do
+ expect { subject }.to change { Packages::Protection::Rule.count }.by(0)
+
+ expect(Packages::Protection::Rule.where(project: project).count).to eq 1
+ expect(
+ Packages::Protection::Rule.where(
+ project: project,
+ package_name_pattern: params[:package_name_pattern]
+ )
+ ).to exist
+ end
+ end
+ end
+
+ context 'when disallowed params are passed' do
+ let(:params) do
+ attributes_for(:package_protection_rule)
+ .merge(
+ project_id: 1,
+ unsupported_param: 'unsupported_param_value'
+ )
+ end
+
+ it_behaves_like 'a successful service response'
+ end
+
+ context 'with forbidden user access level (project developer role)' do
+ # Because of the access level hierarchy, we can assume that
+ # other access levels below developer role will also not be able to
+ # create package protection rules.
+ let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } }
+
+ let(:current_user) { developer }
+
+ it_behaves_like 'an erroneous service response'
+
+ it { is_expected.to have_attributes(message: match(/Unauthorized/)) }
+ end
+end