Commit d54c822b authored by Steve Abrams's avatar Steve Abrams Committed by Bob Van Landuyt

Allow NPM packages to have any scoped name

To allow for the publishing of NPM packages from groups and subgroups,
packages must be able to be named without the forward slash character.
This change removes requiring the package name match the full project
path and name, and updates the query for finding a package by its
name.
parent 91dfd4dd
...@@ -586,6 +586,14 @@ module EE ...@@ -586,6 +586,14 @@ module EE
@design_repository ||= DesignManagement::Repository.new(self) @design_repository ||= DesignManagement::Repository.new(self)
end end
def package_already_taken?(package_name)
namespace.root_ancestor.all_projects
.joins(:packages)
.where.not(id: id)
.merge(Packages::Package.with_name(package_name))
.exists?
end
private private
def set_override_pull_mirror_available def set_override_pull_mirror_available
......
...@@ -12,9 +12,13 @@ class Packages::Package < ApplicationRecord ...@@ -12,9 +12,13 @@ class Packages::Package < ApplicationRecord
presence: true, presence: true,
format: { with: Gitlab::Regex.package_name_regex } format: { with: Gitlab::Regex.package_name_regex }
validate :valid_npm_package_name, if: :npm?
validate :package_already_taken
enum package_type: { maven: 1, npm: 2 } enum package_type: { maven: 1, npm: 2 }
scope :with_name, ->(name) { where(name: name) } scope :with_name, ->(name) { where(name: name) }
scope :with_version, ->(version) { where(version: version) }
scope :has_version, -> { where.not(version: nil) } scope :has_version, -> { where.not(version: nil) }
scope :preload_files, -> { preload(:package_files) } scope :preload_files, -> { preload(:package_files) }
scope :last_of_each_version, -> { where(id: all.select('MAX(id) AS id').group(:version)) } scope :last_of_each_version, -> { where(id: all.select('MAX(id) AS id').group(:version)) }
...@@ -34,4 +38,22 @@ class Packages::Package < ApplicationRecord ...@@ -34,4 +38,22 @@ class Packages::Package < ApplicationRecord
.joins(:package_files) .joins(:package_files)
.where(packages_package_files: { file_name: file_name }).last! .where(packages_package_files: { file_name: file_name }).last!
end end
private
def valid_npm_package_name
return unless project&.root_namespace
unless name =~ %r{\A@#{project.root_namespace.path}/[^/]+\z}
errors.add(:name, 'is not valid')
end
end
def package_already_taken
return unless project
if project.package_already_taken?(name)
errors.add(:base, 'Package already exists')
end
end
end end
...@@ -6,6 +6,10 @@ module Packages ...@@ -6,6 +6,10 @@ module Packages
version = params[:versions].keys.first version = params[:versions].keys.first
version_data = params[:versions][version] version_data = params[:versions][version]
existing_package = project.packages.npm.with_name(name).with_version(version)
return error('Package already exists.', 403) if existing_package.exists?
package = project.packages.create!( package = project.packages.create!(
name: name, name: name,
version: version, version: version,
......
---
title: Add the ability to publish and install NPM packages from groups and subgroups
merge_request: 13986
author:
type: added
...@@ -5,6 +5,10 @@ module API ...@@ -5,6 +5,10 @@ module API
package_name: API::NO_SLASH_URL_PART_REGEX package_name: API::NO_SLASH_URL_PART_REGEX
}.freeze }.freeze
rescue_from ActiveRecord::RecordInvalid do |e|
render_api_error!(e.message, 400)
end
before do before do
require_packages_enabled! require_packages_enabled!
authenticate_non_get! authenticate_non_get!
...@@ -14,15 +18,7 @@ module API ...@@ -14,15 +18,7 @@ module API
helpers do helpers do
def find_project_by_package_name(name) def find_project_by_package_name(name)
Project.find_by_full_path(name.sub('@', '')) ::Packages::Package.npm.with_name(name).first&.project
end
def project_package_name_match?
"@#{user_project.full_path}" == params[:package_name]
end
def ensure_project_package_match!
bad_request!(:package_name) unless project_package_name_match?
end end
end end
...@@ -35,7 +31,6 @@ module API ...@@ -35,7 +31,6 @@ module API
get 'packages/npm/*package_name', format: false, requirements: NPM_ENDPOINT_REQUIREMENTS do get 'packages/npm/*package_name', format: false, requirements: NPM_ENDPOINT_REQUIREMENTS do
package_name = params[:package_name] package_name = params[:package_name]
# To avoid name collision we require project path and project package be the same.
project = find_project_by_package_name(package_name) project = find_project_by_package_name(package_name)
authorize!(:read_package, project) authorize!(:read_package, project)
...@@ -54,7 +49,6 @@ module API ...@@ -54,7 +49,6 @@ module API
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
before do before do
authorize_packages_feature! authorize_packages_feature!
ensure_project_package_match!
end end
desc 'Download the NPM tarball' do desc 'Download the NPM tarball' do
...@@ -86,8 +80,14 @@ module API ...@@ -86,8 +80,14 @@ module API
put ':id/packages/npm/:package_name', requirements: NPM_ENDPOINT_REQUIREMENTS do put ':id/packages/npm/:package_name', requirements: NPM_ENDPOINT_REQUIREMENTS do
authorize_create_package! authorize_create_package!
::Packages::CreateNpmPackageService created_package = ::Packages::CreateNpmPackageService
.new(user_project, current_user, params).execute .new(user_project, current_user, params).execute
if created_package[:status] == :error
render_api_error!(created_package[:message], created_package[:http_status])
else
created_package
end
end end
end end
end end
......
...@@ -21,7 +21,7 @@ FactoryBot.define do ...@@ -21,7 +21,7 @@ FactoryBot.define do
end end
factory :npm_package do factory :npm_package do
name 'foo' sequence(:name) { |n| "@#{project.root_namespace.path}/package-#{n}"}
version '1.0.0' version '1.0.0'
package_type 'npm' package_type 'npm'
......
...@@ -32,28 +32,36 @@ RSpec.describe Packages::Package, type: :model do ...@@ -32,28 +32,36 @@ RSpec.describe Packages::Package, type: :model do
end end
end end
describe '.last_of_each_version' do context 'version scopes' do
let!(:package1) { create(:npm_package, version: '1.0.0') } let!(:package1) { create(:npm_package, version: '1.0.0') }
let!(:package2) { create(:npm_package, version: '1.0.1') } let!(:package2) { create(:npm_package, version: '1.0.1') }
let!(:package3) { create(:npm_package, version: '1.0.1') } let!(:package3) { create(:npm_package, version: '1.0.1') }
subject { described_class.last_of_each_version } describe '.last_of_each_version' do
subject { described_class.last_of_each_version }
it 'includes only latest package per version' do it 'includes only latest package per version' do
is_expected.to include(package1, package3) is_expected.to include(package1, package3)
is_expected.not_to include(package2) is_expected.not_to include(package2)
end
end end
end
describe '.has_version' do describe '.has_version' do
let!(:package1) { create(:npm_package, version: '1.0.0') } let!(:package4) { create(:npm_package, version: nil) }
let!(:package2) { create(:npm_package, version: nil) }
let!(:package3) { create(:npm_package, version: '1.0.1') } subject { described_class.has_version }
it 'includes only packages with version attribute' do
is_expected.to match_array([package1, package2, package3])
end
end
subject { described_class.has_version } describe '.with_version' do
subject { described_class.with_version('1.0.1') }
it 'includes only packages with version attribute' do it 'includes only packages with specified version' do
is_expected.to match_array([package1, package3]) is_expected.to match_array([package2, package3])
end
end end
end end
end end
...@@ -2143,4 +2143,31 @@ describe Project do ...@@ -2143,4 +2143,31 @@ describe Project do
.to receive(:default_url_options) .to receive(:default_url_options)
.and_return(host: host) .and_return(host: host)
end end
describe '#package_already_taken?' do
let(:namespace) { create(:namespace) }
let(:project) { create(:project, :public, namespace: namespace) }
let!(:package) { create(:npm_package, project: project, name: "@#{namespace.path}/foo") }
context 'no package exists with the same name' do
it 'returns false' do
result = project.package_already_taken?("@#{namespace.path}/bar")
expect(result).to be false
end
it 'returns false if it is the project that the package belongs to' do
result = project.package_already_taken?("@#{namespace.path}/foo")
expect(result).to be false
end
end
context 'a package already exists with the same name' do
let(:alt_project) { create(:project, :public, namespace: namespace) }
it 'returns true' do
result = alt_project.package_already_taken?("@#{namespace.path}/foo")
expect(result).to be true
end
end
end
end end
...@@ -27,7 +27,7 @@ describe API::NpmPackages do ...@@ -27,7 +27,7 @@ describe API::NpmPackages do
end end
describe 'GET /api/v4/packages/npm/*package_name' do describe 'GET /api/v4/packages/npm/*package_name' do
let(:package) { create(:npm_package, project: project, name: "@#{project.full_path}") } let(:package) { create(:npm_package, project: project) }
context 'a public project' do context 'a public project' do
it 'returns the package info without oauth token' do it 'returns the package info without oauth token' do
...@@ -80,16 +80,6 @@ describe API::NpmPackages do ...@@ -80,16 +80,6 @@ describe API::NpmPackages do
expect(response).to have_gitlab_http_status(403) expect(response).to have_gitlab_http_status(403)
end end
context 'project name is different from a package name' do
let(:package) { create(:npm_package, project: project) }
it 'rejects request' do
get_package(package)
expect(response).to have_gitlab_http_status(403)
end
end
def get_package(package, params = {}) def get_package(package, params = {})
get api("/packages/npm/#{package.name}"), params: params get api("/packages/npm/#{package.name}"), params: params
end end
...@@ -100,7 +90,7 @@ describe API::NpmPackages do ...@@ -100,7 +90,7 @@ describe API::NpmPackages do
end end
describe 'GET /api/v4/projects/:id/packages/npm/*package_name/-/*file_name' do describe 'GET /api/v4/projects/:id/packages/npm/*package_name/-/*file_name' do
let(:package) { create(:npm_package, project: project, name: "@#{project.full_path}") } let(:package) { create(:npm_package, project: project) }
let(:package_file) { package.package_files.first } let(:package_file) { package.package_files.first }
context 'a public project' do context 'a public project' do
...@@ -159,20 +149,34 @@ describe API::NpmPackages do ...@@ -159,20 +149,34 @@ describe API::NpmPackages do
describe 'PUT /api/v4/projects/:id/packages/npm/:package_name' do describe 'PUT /api/v4/projects/:id/packages/npm/:package_name' do
context 'when params are correct' do context 'when params are correct' do
context 'unscoped package' do context 'invalid package record' do
let(:package_name) { project.path } context 'unscoped package' do
let(:params) { upload_params(package_name) } let(:package_name) { 'my_unscoped_package' }
let(:params) { upload_params(package_name) }
it 'denies the request with 400 error' do it 'handles an ActiveRecord::RecordInvalid exception with 400 error' do
expect { upload_package_with_token(package_name, params) } expect { upload_package_with_token(package_name, params) }
.not_to change { project.packages.count } .not_to change { project.packages.count }
expect(response).to have_gitlab_http_status(400)
end
end
context 'invalid package name' do
let(:package_name) { "@#{group.path}/my_inv@lid_package_name" }
let(:params) { upload_params(package_name) }
it 'handles an ActiveRecord::RecordInvalid exception with 400 error' do
expect { upload_package_with_token(package_name, params) }
.not_to change { project.packages.count }
expect(response).to have_gitlab_http_status(400) expect(response).to have_gitlab_http_status(400)
end
end end
end end
context 'scoped package' do context 'scoped package' do
let(:package_name) { "@#{project.full_path}" } let(:package_name) { "@#{group.path}/my_package_name" }
let(:params) { upload_params(package_name) } let(:params) { upload_params(package_name) }
it 'creates npm package with file' do it 'creates npm package with file' do
...@@ -183,6 +187,19 @@ describe API::NpmPackages do ...@@ -183,6 +187,19 @@ describe API::NpmPackages do
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
end end
end end
context 'package creation fails' do
let(:package_name) { "@#{group.path}/my_package_name" }
let(:params) { upload_params(package_name) }
it 'returns an error if the package already exists' do
create(:npm_package, project: project, version: '1.0.1', name: "@#{group.path}/my_package_name")
expect { upload_package_with_token(package_name, params) }
.not_to change { project.packages.count }
expect(response).to have_gitlab_http_status(403)
end
end
end end
def upload_package(package_name, params = {}) def upload_package(package_name, params = {})
......
...@@ -2,7 +2,8 @@ ...@@ -2,7 +2,8 @@
require 'spec_helper' require 'spec_helper'
describe Packages::CreateNpmPackageService do describe Packages::CreateNpmPackageService do
let(:project) { create(:project) } let(:namespace) {create(:namespace)}
let(:project) { create(:project, namespace: namespace) }
let(:user) { create(:user) } let(:user) { create(:user) }
let(:version) { '1.0.1'.freeze } let(:version) { '1.0.1'.freeze }
...@@ -26,15 +27,41 @@ describe Packages::CreateNpmPackageService do ...@@ -26,15 +27,41 @@ describe Packages::CreateNpmPackageService do
describe '#execute' do describe '#execute' do
context 'scoped package' do context 'scoped package' do
let(:package_name) { '@gitlab/my-app'.freeze } let(:package_name) { "@#{namespace.path}/my-app".freeze }
it_behaves_like 'valid package' it_behaves_like 'valid package'
end end
context 'normal package' do context 'invalid package name' do
let(:package_name) { 'my-app'.freeze } let(:package_name) { "@#{namespace.path}/my-group/my-app".freeze }
it_behaves_like 'valid package' it 'raises a RecordInvalid error' do
service = described_class.new(project, user, params)
expect { service.execute }.to raise_error(ActiveRecord::RecordInvalid)
end
end
context 'package already exists' do
let(:package_name) { "@#{namespace.path}/my_package" }
it 'returns a 403 error' do
create(:npm_package, project: project, name: package_name, version: '1.0.1')
response = described_class.new(project, user, params).execute
expect(response[:http_status]).to eq 403
expect(response[:message]).to be 'Package already exists.'
end
end
context 'with incorrect namespace' do
let(:package_name) { '@my_other_namespace/my-app' }
it 'raises a RecordInvalid error' do
service = described_class.new(project, user, params)
expect { service.execute }.to raise_error(ActiveRecord::RecordInvalid)
end
end end
end end
end end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment