From 42a2d94bac523ec8cbd000609c49a38b84a72b46 Mon Sep 17 00:00:00 2001 From: Gerardo Navarro <gerardo@b310.de> Date: Thu, 11 Apr 2024 15:40:12 +0000 Subject: [PATCH] Protected packages: Show protected label in packages list MR includes: - Feature flag switch to enable / disable the protected label - Add badge to package in package list - Extend graphql api to include package protection rules in graphql type Types::Packages::PackageBaseType - Add association in model Packages::Package for matching package protection rules - This commit is only a proof of concept in order to see if this is going in the right direction Changelog: added --- .../components/list/package_list_row.vue | 25 ++++++ .../fragments/package_data.fragment.graphql | 1 + .../projects/packages/packages_controller.rb | 10 +++ .../types/packages/package_base_type.rb | 12 +++ app/models/packages/package.rb | 1 + doc/api/graphql/reference/index.md | 3 + locale/gitlab.pot | 3 + .../graphql/packages/package_details.json | 3 + .../components/list/package_list_row_spec.js | 59 ++++++++++++- .../package_registry/mock_data.js | 2 + spec/models/packages/package_spec.rb | 21 +++++ .../api/graphql/project/packages_spec.rb | 84 +++++++++++++++++++ .../packages/packages_controller_spec.rb | 33 ++++++++ 13 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 spec/requests/projects/packages/packages_controller_spec.rb diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue index 126eb9c099173..892e8ac79f5ea 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue @@ -7,6 +7,8 @@ import { GlSprintf, GlTruncate, GlLink, + GlBadge, + GlTooltipDirective, } from '@gitlab/ui'; import { s__, __ } from '~/locale'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; @@ -23,6 +25,7 @@ import PackageTags from '~/packages_and_registries/shared/components/package_tag import PublishMethod from '~/packages_and_registries/package_registry/components/list/publish_method.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'PackageListRow', @@ -34,11 +37,16 @@ export default { GlSprintf, GlTruncate, GlLink, + GlBadge, PackageTags, PublishMethod, ListItem, TimeagoTooltip, }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [glFeatureFlagsMixin()], inject: ['isGroupPage', 'canDeletePackages'], props: { packageEntity: { @@ -98,6 +106,12 @@ export default { showTags() { return Boolean(this.packageEntity.tags?.nodes?.length); }, + showBadgeProtected() { + return ( + Boolean(this.glFeatures.packagesProtectedPackages) && + Boolean(this.packageEntity.packageProtectionRuleExists) + ); + }, nonDefaultRow() { return this.packageEntity.status && this.packageEntity.status !== PACKAGE_DEFAULT_STATUS; }, @@ -114,6 +128,7 @@ export default { errorPublishing: ERROR_PUBLISHING, warning: WARNING_TEXT, moreActions: __('More actions'), + badgeProtectedTooltipText: s__('PackageRegistry|A protection rule exists for this package.'), }, }; </script> @@ -148,6 +163,16 @@ export default { hide-label :tag-display-limit="1" /> + + <gl-badge + v-if="showBadgeProtected" + v-gl-tooltip="{ title: $options.i18n.badgeProtectedTooltipText }" + class="gl-ml-3" + icon-size="sm" + size="sm" + variant="neutral" + >{{ __('protected') }}</gl-badge + > </div> </template> <template #left-secondary> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql index fe0dbecfafcb1..bad9dc77523c9 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql @@ -30,6 +30,7 @@ fragment PackageData on Package { fullPath webUrl } + packageProtectionRuleExists _links { webPath } diff --git a/app/controllers/projects/packages/packages_controller.rb b/app/controllers/projects/packages/packages_controller.rb index f045bae5c9694..96fea1340d957 100644 --- a/app/controllers/projects/packages/packages_controller.rb +++ b/app/controllers/projects/packages/packages_controller.rb @@ -8,10 +8,20 @@ class PackagesController < Projects::ApplicationController feature_category :package_registry urgency :low + before_action :set_feature_flag_packages_protected_packages, only: :index + + def index; end + # The show action renders index to allow frontend routing to work on page refresh def show render :index end + + private + + def set_feature_flag_packages_protected_packages + push_frontend_feature_flag(:packages_protected_packages, project) + end end end end diff --git a/app/graphql/types/packages/package_base_type.rb b/app/graphql/types/packages/package_base_type.rb index 5102e4ebcd594..ef2cdf983ec5b 100644 --- a/app/graphql/types/packages/package_base_type.rb +++ b/app/graphql/types/packages/package_base_type.rb @@ -28,6 +28,12 @@ class PackageBaseType < ::Types::BaseObject null: true, description: 'Package metadata.' field :name, GraphQL::Types::String, null: false, description: 'Name of the package.' + field :package_protection_rule_exists, GraphQL::Types::Boolean, + null: false, + alpha: { milestone: '16.11' }, + description: + 'Whether any matching package protection rule exists for this package. ' \ + 'Available only when feature flag `packages_protected_packages` is enabled.' field :package_type, Types::Packages::PackageTypeEnum, null: false, description: 'Package type.' field :project, Types::ProjectType, null: false, description: 'Project where the package is stored.' field :status, Types::Packages::PackageStatusEnum, null: false, description: 'Package status.' @@ -44,6 +50,12 @@ def can_destroy Ability.allowed?(current_user, :destroy_package, object) end + def package_protection_rule_exists + return false if Feature.disabled?(:packages_protected_packages, object.project) + + object.matching_package_protection_rules.exists? + end + # NOTE: This method must be kept in sync with the union # type: `Types::Packages::MetadataType`. # diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index 6be77509f8a1e..0558e1b338d60 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -58,6 +58,7 @@ class Packages::Package < ApplicationRecord has_many :pipelines, through: :build_infos, disable_joins: true has_one :debian_publication, inverse_of: :package, class_name: 'Packages::Debian::Publication' has_one :debian_distribution, through: :debian_publication, source: :distribution, inverse_of: :packages, class_name: 'Packages::Debian::ProjectDistribution' + has_many :matching_package_protection_rules, -> (package) { where(package_type: package.package_type).for_package_name(package.name) }, through: :project, source: :package_protection_rules accepts_nested_attributes_for :conan_metadatum accepts_nested_attributes_for :debian_publication diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 54f0655e7e853..e4b2f56ac72ce 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -24777,6 +24777,7 @@ Represents a package with pipelines in the Package Registry. | <a id="packageid"></a>`id` | [`PackagesPackageID!`](#packagespackageid) | ID of the package. | | <a id="packagemetadata"></a>`metadata` | [`PackageMetadata`](#packagemetadata) | Package metadata. | | <a id="packagename"></a>`name` | [`String!`](#string) | Name of the package. | +| <a id="packagepackageprotectionruleexists"></a>`packageProtectionRuleExists` **{warning-solid}** | [`Boolean!`](#boolean) | **Introduced** in GitLab 16.11. **Status**: Experiment. Whether any matching package protection rule exists for this package. Available only when feature flag `packages_protected_packages` is enabled. | | <a id="packagepackagetype"></a>`packageType` | [`PackageTypeEnum!`](#packagetypeenum) | Package type. | | <a id="packagepipelines"></a>`pipelines` | [`PipelineConnection`](#pipelineconnection) | Pipelines that built the package. Max page size 20. (see [Connections](#connections)) | | <a id="packageproject"></a>`project` | [`Project!`](#project) | Project where the package is stored. | @@ -24801,6 +24802,7 @@ Represents a package in the Package Registry. | <a id="packagebaseid"></a>`id` | [`PackagesPackageID!`](#packagespackageid) | ID of the package. | | <a id="packagebasemetadata"></a>`metadata` | [`PackageMetadata`](#packagemetadata) | Package metadata. | | <a id="packagebasename"></a>`name` | [`String!`](#string) | Name of the package. | +| <a id="packagebasepackageprotectionruleexists"></a>`packageProtectionRuleExists` **{warning-solid}** | [`Boolean!`](#boolean) | **Introduced** in GitLab 16.11. **Status**: Experiment. Whether any matching package protection rule exists for this package. Available only when feature flag `packages_protected_packages` is enabled. | | <a id="packagebasepackagetype"></a>`packageType` | [`PackageTypeEnum!`](#packagetypeenum) | Package type. | | <a id="packagebaseproject"></a>`project` | [`Project!`](#project) | Project where the package is stored. | | <a id="packagebasestatus"></a>`status` | [`PackageStatus!`](#packagestatus) | Package status. | @@ -24871,6 +24873,7 @@ Represents a package details in the Package Registry. | <a id="packagedetailstypenpmurl"></a>`npmUrl` | [`String`](#string) | Url of the NPM project endpoint. | | <a id="packagedetailstypenugeturl"></a>`nugetUrl` | [`String`](#string) | Url of the Nuget project endpoint. | | <a id="packagedetailstypepackagefiles"></a>`packageFiles` | [`PackageFileConnection`](#packagefileconnection) | Package files. (see [Connections](#connections)) | +| <a id="packagedetailstypepackageprotectionruleexists"></a>`packageProtectionRuleExists` **{warning-solid}** | [`Boolean!`](#boolean) | **Introduced** in GitLab 16.11. **Status**: Experiment. Whether any matching package protection rule exists for this package. Available only when feature flag `packages_protected_packages` is enabled. | | <a id="packagedetailstypepackagetype"></a>`packageType` | [`PackageTypeEnum!`](#packagetypeenum) | Package type. | | <a id="packagedetailstypepipelines"></a>`pipelines` | [`PipelineConnection`](#pipelineconnection) | Pipelines that built the package. Max page size 20. (see [Connections](#connections)) | | <a id="packagedetailstypeproject"></a>`project` | [`Project!`](#project) | Project where the package is stored. | diff --git a/locale/gitlab.pot b/locale/gitlab.pot index a85f9159dec31..cf83f409a6946 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -35887,6 +35887,9 @@ msgstr "" msgid "PackageRegistry|%{name} version %{version} was first created %{datetime}" msgstr "" +msgid "PackageRegistry|A protection rule exists for this package." +msgstr "" + msgid "PackageRegistry|Add Conan Remote" msgstr "" diff --git a/spec/fixtures/api/schemas/graphql/packages/package_details.json b/spec/fixtures/api/schemas/graphql/packages/package_details.json index 1acc3f6ad7d79..d01d75fcd46dd 100644 --- a/spec/fixtures/api/schemas/graphql/packages/package_details.json +++ b/spec/fixtures/api/schemas/graphql/packages/package_details.json @@ -262,6 +262,9 @@ "null" ] }, + "packageProtectionRuleExists": { + "type": "boolean" + }, "_links": { "type": "object", "additionalProperties": false, diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js index 499b5cb6fe1cc..7c742326a6b81 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js @@ -1,8 +1,9 @@ -import { GlFormCheckbox, GlSprintf, GlTruncate } from '@gitlab/ui'; +import { GlFormCheckbox, GlSprintf, GlTruncate, GlBadge } from '@gitlab/ui'; import Vue from 'vue'; import VueRouter from 'vue-router'; import { RouterLinkStub } from '@vue/test-utils'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue'; import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; @@ -59,11 +60,15 @@ describe('packages_list_row', () => { GlSprintf, TimeagoTooltip, RouterLink: RouterLinkStub, + GlBadge, }, propsData: { packageEntity, selected, }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, }); }; @@ -316,4 +321,56 @@ describe('packages_list_row', () => { ); }); }); + + describe('badge "protected"', () => { + const mountComponentForBadgeProtected = ({ + packageEntityPackageProtectionRuleExists = true, + glFeaturesPackagesProtectedPackages = true, + } = {}) => + mountComponent({ + packageEntity: { + ...packageWithoutTags, + packageProtectionRuleExists: packageEntityPackageProtectionRuleExists, + }, + provide: { + ...defaultProvide, + glFeatures: { packagesProtectedPackages: glFeaturesPackagesProtectedPackages }, + }, + }); + + const findBadgeProtected = () => wrapper.findComponent(GlBadge); + + describe('when package is protected', () => { + it('shows badge', () => { + mountComponentForBadgeProtected(); + + expect(findBadgeProtected().text()).toBe('protected'); + }); + + it('binds tooltip directive', () => { + mountComponentForBadgeProtected(); + + const badgeProtectedTooltipBinding = getBinding(findBadgeProtected().element, 'gl-tooltip'); + expect(badgeProtectedTooltipBinding.value).toMatchObject({ + title: 'A protection rule exists for this package.', + }); + }); + }); + + describe('when package is not protected', () => { + it('does not show badge', () => { + mountComponentForBadgeProtected({ packageEntityPackageProtectionRuleExists: false }); + + expect(findBadgeProtected().exists()).toBe(false); + }); + }); + + describe('when feature flag ":packages_protected_packages" disabled', () => { + it('does not show badge', () => { + mountComponentForBadgeProtected({ glFeaturesPackagesProtectedPackages: false }); + + expect(findBadgeProtected().exists()).toBe(false); + }); + }); + }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/mock_data.js b/spec/frontend/packages_and_registries/package_registry/mock_data.js index 4b3506d1452d4..9dc0770a26f42 100644 --- a/spec/frontend/packages_and_registries/package_registry/mock_data.js +++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js @@ -431,6 +431,7 @@ export const packagesListQuery = ({ nodes: [ { ...packageData(), + packageProtectionRuleExists: false, ...linksData, project: packageProject(), tags: { nodes: packageTags() }, @@ -440,6 +441,7 @@ export const packagesListQuery = ({ }, { ...packageData(), + packageProtectionRuleExists: false, project: packageProject(), tags: { nodes: [] }, pipelines: { nodes: [] }, diff --git a/spec/models/packages/package_spec.rb b/spec/models/packages/package_spec.rb index 345ff2099db9c..e3569c7caa95c 100644 --- a/spec/models/packages/package_spec.rb +++ b/spec/models/packages/package_spec.rb @@ -26,6 +26,7 @@ it { is_expected.to have_one(:rpm_metadatum).inverse_of(:package) } it { is_expected.to have_one(:terraform_module_metadatum).inverse_of(:package) } it { is_expected.to have_many(:nuget_symbols).inverse_of(:package) } + it { is_expected.to have_many(:matching_package_protection_rules).through(:project).source(:package_protection_rules) } end describe '.with_debian_codename' do @@ -1202,6 +1203,26 @@ end end + describe '#matching_package_protection_rules' do + let_it_be(:package) do + create(:npm_package, name: 'npm-package') + end + + let_it_be(:package_protection_rule) do + create(:package_protection_rule, project: package.project, package_name_pattern: package.name, package_type: :npm, + push_protected_up_to_access_level: :maintainer) + end + + let_it_be(:package_protection_rule_no_match) do + create(:package_protection_rule, project: package.project, package_name_pattern: "other-#{package.name}", package_type: :npm, + push_protected_up_to_access_level: :maintainer) + end + + subject { package.matching_package_protection_rules } + + it { is_expected.to eq [package_protection_rule] } + end + describe '#tag_names' do let_it_be(:package) { create(:nuget_package) } diff --git a/spec/requests/api/graphql/project/packages_spec.rb b/spec/requests/api/graphql/project/packages_spec.rb index 3413b80e8b4da..ad94d285182a8 100644 --- a/spec/requests/api/graphql/project/packages_spec.rb +++ b/spec/requests/api/graphql/project/packages_spec.rb @@ -13,4 +13,88 @@ let(:resource_type) { :project } it_behaves_like 'group and project packages query' + + describe 'packageProtectionRuleExists' do + let_it_be(:maven_package) { create(:maven_package, project: project1, name: 'maven-package') } + let_it_be(:npm_package) { create(:npm_package, project: project1, name: 'npm-package') } + let_it_be(:npm_package_no_match) { create(:npm_package, project: project1, name: 'other-npm-package') } + + let_it_be(:npm_package_protection_rule) do + create(:package_protection_rule, project: resource, package_name_pattern: npm_package.name, package_type: :npm, + push_protected_up_to_access_level: :maintainer) + end + + let(:query) do + graphql_query_for( + resource_type, + { 'fullPath' => resource.full_path }, + query_graphql_field('packages', {}, fields) + ) + end + + let(:fields) do + <<~QUERY + nodes { + name + packageType + packageProtectionRuleExists + } + QUERY + end + + context 'when package protection rule for package and user exists' do + using RSpec::Parameterized::TableSyntax + + where(:current_user_access_level, :expected_package_protection_rule_exists) do + :reporter | true + :developer | true + :maintainer | true + :owner | true + end + + with_them do + before do + resource.send("add_#{current_user_access_level}", current_user) + + post_graphql(query, current_user: current_user) + end + + it_behaves_like 'a working graphql query that returns data' + + it 'returns package protection rules' do + expect(graphql_data_at(resource_type, :packages, :nodes)).to include( + hash_including( + 'name' => npm_package.name, + 'packageType' => npm_package.package_type.upcase, + 'packageProtectionRuleExists' => + expected_package_protection_rule_exists + ), + hash_including( + 'name' => maven_package.name, + 'packageType' => maven_package.package_type.upcase, + 'packageProtectionRuleExists' => false + ) + ) + end + end + end + + context "when feature flag ':packages_protected_packages' disabled" do + before_all do + resource.add_maintainer(current_user) + end + + before do + stub_feature_flags(packages_protected_packages: false) + + post_graphql(query, current_user: current_user) + end + + it 'returns no package protection rules' do + graphql_data_at(resource_type, :packages, :nodes).each do |package| + expect(package['packageProtectionRuleExists']).to eq false + end + end + end + end end diff --git a/spec/requests/projects/packages/packages_controller_spec.rb b/spec/requests/projects/packages/packages_controller_spec.rb new file mode 100644 index 0000000000000..5a3dcbfe72cc9 --- /dev/null +++ b/spec/requests/projects/packages/packages_controller_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::Packages::PackagesController, feature_category: :package_registry do + let_it_be(:project) { create(:project, :public) } + + describe 'GET #index' do + let(:get_namespace_project_packages_path) do + get namespace_project_packages_path(namespace_id: project.namespace, project_id: project) + end + + subject { response.body } + + context 'when feature flag "packages_protected_packages" is enabled' do + before do + get_namespace_project_packages_path + end + + it { is_expected.to have_pushed_frontend_feature_flags(packagesProtectedPackages: true) } + end + + context 'when feature flag "packages_protected_packages" is disabled' do + before do + stub_feature_flags(packages_protected_packages: false) + + get_namespace_project_packages_path + end + + it { is_expected.to have_pushed_frontend_feature_flags(packagesProtectedPackages: false) } + end + end +end -- GitLab