Commit 036548d6 authored by Mayra Cabrera's avatar Mayra Cabrera

Merge branch '11679-conan-project-level' into 'master'

Conan packages scoped to project-level

See merge request gitlab-org/gitlab!39541
parents b0e3a109 1863d47a
......@@ -3,6 +3,9 @@
class Packages::Conan::FileMetadatum < ApplicationRecord
belongs_to :package_file, inverse_of: :conan_file_metadatum
DEFAULT_PACKAGE_REVISION = '0'.freeze
DEFAULT_RECIPE_REVISION = '0'.freeze
validates :package_file, presence: true
validates :recipe_revision,
......
......@@ -3,6 +3,7 @@
module Packages
module Conan
class PackagePresenter
include API::Helpers::Packages::Conan::ApiHelpers
include API::Helpers::RelatedResourcesHelpers
include Gitlab::Utils::StrongMemoize
......@@ -17,7 +18,10 @@ module Packages
def recipe_urls
map_package_files do |package_file|
build_recipe_file_url(package_file) if package_file.conan_file_metadatum.recipe_file?
next unless package_file.conan_file_metadatum.recipe_file?
options = url_options(package_file)
recipe_file_url(options)
end
end
......@@ -31,7 +35,12 @@ module Packages
map_package_files do |package_file|
next unless package_file.conan_file_metadatum.package_file? && matching_reference?(package_file)
build_package_file_url(package_file)
options = url_options(package_file).merge(
conan_package_reference: package_file.conan_file_metadatum.conan_package_reference,
package_revision: package_file.conan_file_metadatum.package_revision
)
package_file_url(options)
end
end
......@@ -45,36 +54,21 @@ module Packages
private
def build_recipe_file_url(package_file)
expose_url(
api_v4_packages_conan_v1_files_export_path(
package_name: @package.name,
package_version: @package.version,
package_username: @package.conan_metadatum.package_username,
package_channel: @package.conan_metadatum.package_channel,
recipe_revision: package_file.conan_file_metadatum.recipe_revision,
file_name: package_file.file_name
)
)
end
def build_package_file_url(package_file)
expose_url(
api_v4_packages_conan_v1_files_package_path(
def url_options(package_file)
{
package_name: @package.name,
package_version: @package.version,
package_username: @package.conan_metadatum.package_username,
package_channel: @package.conan_metadatum.package_channel,
recipe_revision: package_file.conan_file_metadatum.recipe_revision,
conan_package_reference: package_file.conan_file_metadatum.conan_package_reference,
package_revision: package_file.conan_file_metadatum.package_revision,
file_name: package_file.file_name
)
)
file_name: package_file.file_name,
recipe_revision: package_file.conan_file_metadatum.recipe_revision.presence || ::Packages::Conan::FileMetadatum::DEFAULT_RECIPE_REVISION
}
end
def map_package_files
package_files.to_a.map do |package_file|
next unless package_file.conan_file_metadatum
key = package_file.file_name
value = yield(package_file)
next unless key && value
......
---
title: Allow Conan packages to be scoped to project-level
merge_request: 39541
author:
type: added
......@@ -67,7 +67,7 @@ The current state of existing package registries availability is:
| Repository Type | Project Level | Group Level | Instance Level |
|-----------------|---------------|-------------|----------------|
| Maven | Yes | Yes | Yes |
| Conan | No - [open issue](https://gitlab.com/gitlab-org/gitlab/-/issues/11679) | No - [open issue](https://gitlab.com/gitlab-org/gitlab/-/issues/11679) | Yes |
| Conan | Yes | No - [open issue](https://gitlab.com/gitlab-org/gitlab/-/issues/11679) | Yes |
| NPM | No - [open issue](https://gitlab.com/gitlab-org/gitlab/-/issues/36853) | Yes | No - [open issue](https://gitlab.com/gitlab-org/gitlab/-/issues/36853) |
| NuGet | Yes | No - [open issue](https://gitlab.com/gitlab-org/gitlab/-/issues/36423) | No |
| PyPI | Yes | No | No |
......@@ -87,7 +87,7 @@ Composer package naming scope is Instance Level.
To avoid name conflict for instance-level endpoints you will need to define a package naming convention
that gives a way to identify the project that the package belongs to. This generally involves using the project
ID or full project path in the package name. See
[Conan's naming convention](../user/packages/conan_repository/index.md#package-recipe-naming-convention) as an example.
[Conan's naming convention](../user/packages/conan_repository/index.md#package-recipe-naming-convention-for-instance-level-remote) as an example.
For group and project-level endpoints, naming can be less constrained and it will be up to the group and project
members to be certain that there is no conflict between two package names. However, the system should prevent
......
......@@ -83,15 +83,13 @@ conan new Hello/0.1 -t
Next, create a package for that recipe by running `conan create` providing the Conan user and channel:
```shell
conan create . my-org+my-group+my-project/beta
conan create . mycompany/beta
```
NOTE: **Note:**
Current [naming restrictions](#package-recipe-naming-convention) require you to name the `user` value as the `+` separated path of your project on GitLab.
If you are using the [instance level remote](#instance-level-remote), a specific [naming convention](#package-recipe-naming-convention-for-instance-level-remote) must be followed.
The example above would create a package belonging to this project: `https://gitlab.com/my-org/my-group/my-project` with a channel of `beta`.
These two example commands generate a final package with the recipe `Hello/0.1@my-org+my-group+my-project/beta`.
These two example commands generate a final package with the recipe `Hello/0.1@mycompany/beta`.
For more advanced details on creating and managing your packages, refer to the [Conan docs](https://docs.conan.io/en/latest/creating_packages.html).
......@@ -99,6 +97,38 @@ You are now ready to upload your package to the GitLab registry. To get started,
## Adding the GitLab Package Registry as a Conan remote
You can add the GitLab Package Registry as a Conan remote at the project or instance level.
### Project level remote
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/11679) in GitLab 13.4.
The project level remote allows you to work with packages within a given project.
The advantage of using the project level remote is there are no restrictions to your
package name, however all GitLab Conan packages require a full recipe
with the user and channel (`package_name/version@user/channel`).
To add the remote:
```shell
conan remote add gitlab https://gitlab.example.com/api/v4/projects/<project_id>/packages/conan
```
Once the remote is set, you can use the remote when running Conan commands by adding `--remote=gitlab` to the end of your commands.
For example:
```shell
conan search Hello* --all --remote=gitlab
```
### Instance level remote
The instance level remote allows you to use a single remote to access packages accross your entire
GitLab instance. However, when using this remote, there are certain
[package naming restrictions](#package-recipe-naming-convention-for-instance-level-remote)
that must be followed.
Add a new remote to your Conan configuration:
```shell
......@@ -113,6 +143,25 @@ For example:
conan search 'Hello*' --remote=gitlab
```
#### Package recipe naming convention for instance level remote
The standard Conan recipe convention looks like `package_name/version@user/channel`,
but if you're using the [instance level remote](#instance-level-remote), the recipe
`user` must be the plus sign (`+`) separated project path.
The following table shows some example recipes you can give your package based on
the project name and path.
| Project | Package | Supported |
| ---------------------------------- | ----------------------------------------------- | --------- |
| `foo/bar` | `my-package/1.0.0@foo+bar/stable` | Yes |
| `foo/bar-baz/buz` | `my-package/1.0.0@foo+bar-baz+buz/stable` | Yes |
| `gitlab-org/gitlab-ce` | `my-package/1.0.0@gitlab-org+gitlab-ce/stable` | Yes |
| `gitlab-org/gitlab-ce` | `my-package/1.0.0@foo/stable` | No |
NOTE: **Note:**
[Project level remotes](#project-level-remote) allow for more flexible package names.
## Authenticating to the GitLab Conan Repository
You need a personal access token or deploy token.
......@@ -142,7 +191,7 @@ Alternatively, you could explicitly include your credentials in any given comman
For example:
```shell
CONAN_LOGIN_USERNAME=<gitlab_username or deploy_token_username> CONAN_PASSWORD=<personal_access_token or deploy_token> conan upload Hello/0.1@my-group+my-project/beta --all --remote=gitlab
CONAN_LOGIN_USERNAME=<gitlab_username or deploy_token_username> CONAN_PASSWORD=<personal_access_token or deploy_token> conan upload Hello/0.1@mycompany/beta --all --remote=gitlab
```
### Setting a default remote to your project (optional)
......@@ -150,7 +199,7 @@ CONAN_LOGIN_USERNAME=<gitlab_username or deploy_token_username> CONAN_PASSWORD=<
If you'd like Conan to always use GitLab as the registry for your package, you can tell Conan to always reference the GitLab remote for a given package recipe:
```shell
conan remote add_ref Hello/0.1@my-group+my-project/beta gitlab
conan remote add_ref Hello/0.1@mycompany/beta gitlab
```
NOTE: **Note:**
......@@ -165,34 +214,19 @@ The rest of the example commands in this documentation assume that you've added
## Uploading a package
First you need to [create your Conan package locally](https://docs.conan.io/en/latest/creating_packages/getting_started.html). In order to work with the GitLab Package Registry, a specific [naming convention](#package-recipe-naming-convention) must be followed.
First you need to [create your Conan package locally](https://docs.conan.io/en/latest/creating_packages/getting_started.html).
NOTE: **Note:**
If you are using the [instance level remote](#instance-level-remote), a specific [naming convention](#package-recipe-naming-convention-for-instance-level-remote) must be followed.
Ensure you have a project created on GitLab and that the personal access token you're using has the correct permissions for write access to the container registry by selecting the `api` [scope](../../../user/profile/personal_access_tokens.md#limiting-scopes-of-a-personal-access-token).
You can upload your package to the GitLab Package Registry using the `conan upload` command:
```shell
conan upload Hello/0.1@my-group+my-project/beta --all
conan upload Hello/0.1@mycompany/beta --all
```
### Package recipe naming convention
Standard Conan recipe convention looks like `package_name/version@user/channel`.
**The recipe user must be the `+` separated project path**. The package
name may be anything, but it is preferred that the project name be used unless
it's not possible due to a naming collision. For example:
| Project | Package | Supported |
| ---------------------------------- | ----------------------------------------------- | --------- |
| `foo/bar` | `my-package/1.0.0@foo+bar/stable` | Yes |
| `foo/bar-baz/buz` | `my-package/1.0.0@foo+bar-baz+buz/stable` | Yes |
| `gitlab-org/gitlab-ce` | `my-package/1.0.0@gitlab-org+gitlab-ce/stable` | Yes |
| `gitlab-org/gitlab-ce` | `my-package/1.0.0@foo/stable` | No |
NOTE: **Note:**
A future iteration will extend support to [project and group level](https://gitlab.com/gitlab-org/gitlab/-/issues/11679) remotes which allows for more flexible naming conventions.
## Installing a package
Conan packages are commonly installed as dependencies using the `conanfile.txt` file.
......@@ -204,7 +238,7 @@ Add the Conan recipe to the `[requires]` section of the file:
```ini
[requires]
Hello/0.1@my-group+my-project/beta
Hello/0.1@mycompany/beta
[generators]
cmake
......@@ -253,7 +287,7 @@ To search using a partial name, use the wildcard symbol `*`, which should be pla
```shell
conan search Hello --all --remote=gitlab
conan search He* --all --remote=gitlab
conan search Hello/0.1@my-group+my-project/beta --all --remote=gitlab
conan search Hello/0.1@mycompany/beta --all --remote=gitlab
```
The scope of your search includes all projects you have permission to access, this includes your private projects as well as all public projects.
......@@ -263,7 +297,7 @@ The scope of your search includes all projects you have permission to access, th
The `conan info` command returns information about a given package:
```shell
conan info Hello/0.1@my-group+my-project/beta
conan info Hello/0.1@mycompany/beta
```
## List of supported CLI commands
......
......@@ -194,7 +194,8 @@ module API
mount ::API::NugetPackages
mount ::API::PypiPackages
mount ::API::ComposerPackages
mount ::API::ConanPackages
mount ::API::ConanProjectPackages
mount ::API::ConanInstancePackages
mount ::API::MavenPackages
mount ::API::NpmPackages
mount ::API::GenericPackages
......
# frozen_string_literal: true
# Conan Instance-Level Package Manager Client API
module API
class ConanInstancePackages < Grape::API::Instance
namespace 'packages/conan/v1' do
include ConanPackageEndpoints
end
end
end
......@@ -9,8 +9,8 @@
#
# Technical debt: https://gitlab.com/gitlab-org/gitlab/issues/35798
module API
class ConanPackages < Grape::API::Instance
helpers ::API::Helpers::PackagesManagerClientsHelpers
module ConanPackageEndpoints
extend ActiveSupport::Concern
PACKAGE_REQUIREMENTS = {
package_name: API::NO_SLASH_URL_PART_REGEX,
......@@ -28,6 +28,11 @@ module API
CONAN_FILES = (Gitlab::Regex::Packages::CONAN_RECIPE_FILES + Gitlab::Regex::Packages::CONAN_PACKAGE_FILES).freeze
included do
helpers ::API::Helpers::PackagesManagerClientsHelpers
helpers ::API::Helpers::Packages::Conan::ApiHelpers
helpers ::API::Helpers::RelatedResourcesHelpers
before do
require_packages_enabled!
......@@ -36,7 +41,6 @@ module API
authenticate!
end
namespace 'packages/conan/v1' do
desc 'Ping the Conan API' do
detail 'This feature was introduced in GitLab 12.2'
end
......@@ -242,7 +246,7 @@ module API
delete do
authorize!(:destroy_package, project)
package_event('delete_package')
package_event('delete_package', category: 'API::ConanPackages')
package.destroy
end
......@@ -341,11 +345,5 @@ module API
end
end
end
helpers do
include Gitlab::Utils::StrongMemoize
include ::API::Helpers::RelatedResourcesHelpers
include ::API::Helpers::Packages::Conan::ApiHelpers
end
end
end
# frozen_string_literal: true
# Conan Project-Level Package Manager Client API
module API
class ConanProjectPackages < Grape::API::Instance
params do
requires :id, type: Integer, desc: 'The ID of a project', regexp: %r{\A[1-9]\d*\z}
end
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
namespace ':id/packages/conan/v1' do
include ConanPackageEndpoints
end
end
end
end
......@@ -5,6 +5,8 @@ module API
module Packages
module Conan
module ApiHelpers
include Gitlab::Utils::StrongMemoize
def present_download_urls(entity)
authorize!(:read_package, project)
......@@ -31,7 +33,7 @@ module API
def recipe_upload_urls
{ upload_urls: Hash[
file_names.select(&method(:recipe_file?)).map do |file_name|
[file_name, recipe_file_upload_url(file_name)]
[file_name, build_recipe_file_upload_url(file_name)]
end
] }
end
......@@ -39,7 +41,7 @@ module API
def package_upload_urls
{ upload_urls: Hash[
file_names.select(&method(:package_file?)).map do |file_name|
[file_name, package_file_upload_url(file_name)]
[file_name, build_package_file_upload_url(file_name)]
end
] }
end
......@@ -52,32 +54,58 @@ module API
file_name.in?(::Packages::Conan::FileMetadatum::PACKAGE_FILES)
end
def package_file_upload_url(file_name)
expose_url(
api_v4_packages_conan_v1_files_package_path(
def build_package_file_upload_url(file_name)
options = url_options(file_name).merge(
conan_package_reference: params[:conan_package_reference],
package_revision: ::Packages::Conan::FileMetadatum::DEFAULT_PACKAGE_REVISION
)
package_file_url(options)
end
def build_recipe_file_upload_url(file_name)
recipe_file_url(url_options(file_name))
end
def url_options(file_name)
{
package_name: params[:package_name],
package_version: params[:package_version],
package_username: params[:package_username],
package_channel: params[:package_channel],
recipe_revision: '0',
conan_package_reference: params[:conan_package_reference],
package_revision: '0',
file_name: file_name
file_name: file_name,
recipe_revision: ::Packages::Conan::FileMetadatum::DEFAULT_RECIPE_REVISION
}
end
def package_file_url(options)
case package_scope
when :project
expose_url(
api_v4_projects_packages_conan_v1_files_package_path(
options.merge(id: project.id)
)
)
when :instance
expose_url(
api_v4_packages_conan_v1_files_package_path(options)
)
end
end
def recipe_file_upload_url(file_name)
def recipe_file_url(options)
case package_scope
when :project
expose_url(
api_v4_packages_conan_v1_files_export_path(
package_name: params[:package_name],
package_version: params[:package_version],
package_username: params[:package_username],
package_channel: params[:package_channel],
recipe_revision: '0',
file_name: file_name
api_v4_projects_packages_conan_v1_files_export_path(
options.merge(id: project.id)
)
)
when :instance
expose_url(
api_v4_packages_conan_v1_files_export_path(options)
)
end
end
def recipe
......@@ -86,8 +114,13 @@ module API
def project
strong_memoize(:project) do
case package_scope
when :project
find_project!(params[:id])
when :instance
full_path = ::Packages::Conan::Metadatum.full_path_from(package_username: params[:package_username])
Project.find_by_full_path(full_path)
find_project!(full_path)
end
end
end
......@@ -97,6 +130,7 @@ module API
.conan
.with_name(params[:package_name])
.with_version(params[:package_version])
.with_conan_username(params[:package_username])
.with_conan_channel(params[:package_channel])
.order_created
.last
......@@ -124,7 +158,7 @@ module API
conan_package_reference: params[:conan_package_reference]
).execute!
package_event('pull_package') if params[:file_name] == ::Packages::Conan::FileMetadatum::PACKAGE_BINARY
package_event('pull_package', category: 'API::ConanPackages') if params[:file_name] == ::Packages::Conan::FileMetadatum::PACKAGE_BINARY
present_carrierwave_file!(package_file.file)
end
......@@ -135,7 +169,7 @@ module API
def track_push_package_event
if params[:file_name] == ::Packages::Conan::FileMetadatum::PACKAGE_BINARY && params[:file].size > 0 # rubocop: disable Style/ZeroLengthPredicate
package_event('push_package')
package_event('push_package', category: 'API::ConanPackages')
end
end
......@@ -236,6 +270,10 @@ module API
token
end
def package_scope
params[:id].present? ? :project : :instance
end
end
end
end
......
......@@ -7,15 +7,34 @@ RSpec.describe ::Packages::Conan::PackagePresenter do
let_it_be(:package) { create(:conan_package) }
let_it_be(:project) { package.project }
let_it_be(:conan_package_reference) { '123456789'}
let(:params) { { package_scope: :instance } }
describe '#recipe_urls' do
subject { described_class.new(package, user, project).recipe_urls }
context 'no existing package' do
shared_examples 'no existing package' do
context 'when package does not exist' do
let(:package) { nil }
it { is_expected.to be_empty }
end
end
shared_examples 'conan_file_metadatum is not found' do
context 'when no conan_file_metadatum exists' do
before do
package.package_files.each do |file|
file.conan_file_metadatum.delete
file.reload
end
end
it { is_expected.to be_empty }
end
end
describe '#recipe_urls' do
subject { described_class.new(package, user, project, params).recipe_urls }
it_behaves_like 'no existing package'
it_behaves_like 'conan_file_metadatum is not found'
context 'existing package' do
let(:expected_result) do
......@@ -33,17 +52,28 @@ RSpec.describe ::Packages::Conan::PackagePresenter do
it { is_expected.to eq(expected_result) }
end
context 'with package_scope of project' do
# #recipe_file_url checks for params[:id]
let(:params) { { id: project.id } }
let(:expected_result) do
{
"conanfile.py" => "#{Settings.build_base_gitlab_url}/api/v4/projects/#{project.id}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanfile.py",
"conanmanifest.txt" => "#{Settings.build_base_gitlab_url}/api/v4/projects/#{project.id}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt"
}
end
it { is_expected.to eq(expected_result) }
end
end
end
describe '#recipe_snapshot' do
subject { described_class.new(package, user, project).recipe_snapshot }
context 'no existing package' do
let(:package) { nil }
it { is_expected.to be_empty }
end
it_behaves_like 'no existing package'
it_behaves_like 'conan_file_metadatum is not found'
context 'existing package' do
let(:expected_result) do
......@@ -60,17 +90,21 @@ RSpec.describe ::Packages::Conan::PackagePresenter do
describe '#package_urls' do
let(:reference) { conan_package_reference }
let(:params) do
{
conan_package_reference: reference,
package_scope: :instance
}
end
subject do
described_class.new(
package, user, project, conan_package_reference: reference
package, user, project, params
).package_urls
end
context 'no existing package' do
let(:package) { nil }
it { is_expected.to be_empty }
end
it_behaves_like 'no existing package'
it_behaves_like 'conan_file_metadatum is not found'
context 'existing package' do
let(:expected_result) do
......@@ -83,6 +117,26 @@ RSpec.describe ::Packages::Conan::PackagePresenter do
it { is_expected.to eq(expected_result) }
context 'with package_scope of project' do
# #package_file_url checks for params[:id]
let(:params) do
{
conan_package_reference: reference,
id: project.id
}
end
let(:expected_result) do
{
"conaninfo.txt" => "#{Settings.build_base_gitlab_url}/api/v4/projects/#{project.id}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/#{conan_package_reference}/0/conaninfo.txt",
"conanmanifest.txt" => "#{Settings.build_base_gitlab_url}/api/v4/projects/#{project.id}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/#{conan_package_reference}/0/conanmanifest.txt",
"conan_package.tgz" => "#{Settings.build_base_gitlab_url}/api/v4/projects/#{project.id}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/#{conan_package_reference}/0/conan_package.tgz"
}
end
it { is_expected.to eq(expected_result) }
end
context 'multiple packages with different references' do
let(:info_file) { create(:conan_package_file, :conan_package_info, package: package) }
let(:manifest_file) { create(:conan_package_file, :conan_package_manifest, package: package) }
......@@ -132,11 +186,8 @@ RSpec.describe ::Packages::Conan::PackagePresenter do
).package_snapshot
end
context 'no existing package' do
let(:package) { nil }
it { is_expected.to be_empty }
end
it_behaves_like 'no existing package'
it_behaves_like 'conan_file_metadatum is not found'
context 'existing package' do
let(:expected_result) do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe API::ConanInstancePackages do
include_context 'conan api setup'
describe 'GET /api/v4/packages/conan/v1/ping' do
let_it_be(:url) { '/packages/conan/v1/ping' }
it_behaves_like 'conan ping endpoint'
end
describe 'GET /api/v4/packages/conan/v1/conans/search' do
let_it_be(:url) { '/packages/conan/v1/conans/search' }
it_behaves_like 'conan search endpoint'
end
describe 'GET /api/v4/packages/conan/v1/users/authenticate' do
let_it_be(:url) { '/packages/conan/v1/users/authenticate' }
it_behaves_like 'conan authenticate endpoint'
end
describe 'GET /api/v4/packages/conan/v1/users/check_credentials' do
let_it_be(:url) { "/packages/conan/v1/users/check_credentials" }
it_behaves_like 'conan check_credentials endpoint'
end
context 'recipe endpoints' do
include_context 'conan recipe endpoints'
let(:project_id) { 9999 }
let(:url_prefix) { "#{Settings.gitlab.base_url}/api/v4" }
describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel' do
let(:recipe_path) { package.conan_recipe_path }
let(:url) { "/packages/conan/v1/conans/#{recipe_path}" }
it_behaves_like 'recipe snapshot endpoint'
end
describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference' do
let(:recipe_path) { package.conan_recipe_path }
let(:url) { "/packages/conan/v1/conans/#{recipe_path}/packages/#{conan_package_reference}" }
it_behaves_like 'package snapshot endpoint'
end
describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/digest' do
subject { get api("/packages/conan/v1/conans/#{recipe_path}/digest"), headers: headers }
it_behaves_like 'recipe download_urls endpoint'
end
describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference/download_urls' do
subject { get api("/packages/conan/v1/conans/#{recipe_path}/packages/#{conan_package_reference}/download_urls"), headers: headers }
it_behaves_like 'package download_urls endpoint'
end
describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/download_urls' do
subject { get api("/packages/conan/v1/conans/#{recipe_path}/download_urls"), headers: headers }
it_behaves_like 'recipe download_urls endpoint'
end
describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference/digest' do
subject { get api("/packages/conan/v1/conans/#{recipe_path}/packages/#{conan_package_reference}/digest"), headers: headers }
it_behaves_like 'package download_urls endpoint'
end
describe 'POST /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/upload_urls' do
subject { post api("/packages/conan/v1/conans/#{recipe_path}/upload_urls"), params: params.to_json, headers: headers }
it_behaves_like 'recipe upload_urls endpoint'
end
describe 'POST /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference/upload_urls' do
subject { post api("/packages/conan/v1/conans/#{recipe_path}/packages/123456789/upload_urls"), params: params.to_json, headers: headers }
it_behaves_like 'package upload_urls endpoint'
end
describe 'DELETE /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel' do
subject { delete api("/packages/conan/v1/conans/#{recipe_path}"), headers: headers}
it_behaves_like 'delete package endpoint'
end
end
context 'file download endpoints' do
include_context 'conan file download endpoints'
describe 'GET /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/
:recipe_revision/export/:file_name' do
subject do
get api("/packages/conan/v1/files/#{recipe_path}/#{metadata.recipe_revision}/export/#{recipe_file.file_name}"),
headers: headers
end
it_behaves_like 'recipe file download endpoint'
it_behaves_like 'project not found by recipe'
end
describe 'GET /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/
:recipe_revision/package/:conan_package_reference/:package_revision/:file_name' do
subject do
get api("/packages/conan/v1/files/#{recipe_path}/#{metadata.recipe_revision}/package/#{metadata.conan_package_reference}/#{metadata.package_revision}/#{package_file.file_name}"),
headers: headers
end
it_behaves_like 'package file download endpoint'
it_behaves_like 'project not found by recipe'
end
end
context 'file upload endpoints' do
include_context 'conan file upload endpoints'
describe 'PUT /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:file_name/authorize' do
let(:file_name) { 'conanfile.py' }
subject { put api("/packages/conan/v1/files/#{recipe_path}/0/export/#{file_name}/authorize"), headers: headers_with_token }
it_behaves_like 'workhorse authorize endpoint'
end
describe 'PUT /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:conan_package_reference/:package_revision/:file_name/authorize' do
let(:file_name) { 'conaninfo.txt' }
subject { put api("/packages/conan/v1/files/#{recipe_path}/0/package/123456789/0/#{file_name}/authorize"), headers: headers_with_token }
it_behaves_like 'workhorse authorize endpoint'
end
describe 'PUT /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:file_name' do
let(:url) { "/api/v4/packages/conan/v1/files/#{recipe_path}/0/export/#{file_name}" }
it_behaves_like 'workhorse recipe file upload endpoint'
end
describe 'PUT /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:conan_package_reference/:package_revision/:file_name' do
let(:url) { "/api/v4/packages/conan/v1/files/#{recipe_path}/0/package/123456789/0/#{file_name}" }
it_behaves_like 'workhorse package file upload endpoint'
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe API::ConanPackages do
include WorkhorseHelpers
include HttpBasicAuthHelpers
include PackagesManagerApiSpecHelpers
let(:package) { create(:conan_package) }
let_it_be(:personal_access_token) { create(:personal_access_token) }
let_it_be(:user) { personal_access_token.user }
let(:project) { package.project }
let(:base_secret) { SecureRandom.base64(64) }
let(:auth_token) { personal_access_token.token }
let(:job) { create(:ci_build, user: user, status: :running) }
let(:job_token) { job.token }
let(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) }
let(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) }
let(:headers) do
{ 'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Basic.encode_credentials('foo', auth_token) }
end
let(:jwt_secret) do
OpenSSL::HMAC.hexdigest(
OpenSSL::Digest::SHA256.new,
base_secret,
Gitlab::ConanToken::HMAC_KEY
)
end
before do
project.add_developer(user)
allow(Settings).to receive(:attr_encrypted_db_key_base).and_return(base_secret)
end
describe 'GET /api/v4/packages/conan/v1/ping' do
it 'responds with 401 Unauthorized when no token provided' do
get api('/packages/conan/v1/ping')
expect(response).to have_gitlab_http_status(:unauthorized)
end
it 'responds with 200 OK when valid token is provided' do
jwt = build_jwt(personal_access_token)
get api('/packages/conan/v1/ping'), headers: build_token_auth_header(jwt.encoded)
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers['X-Conan-Server-Capabilities']).to eq("")
end
it 'responds with 200 OK when valid job token is provided' do
jwt = build_jwt_from_job(job)
get api('/packages/conan/v1/ping'), headers: build_token_auth_header(jwt.encoded)
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers['X-Conan-Server-Capabilities']).to eq("")
end
it 'responds with 200 OK when valid deploy token is provided' do
jwt = build_jwt_from_deploy_token(deploy_token)
get api('/packages/conan/v1/ping'), headers: build_token_auth_header(jwt.encoded)
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers['X-Conan-Server-Capabilities']).to eq("")
end
it 'responds with 401 Unauthorized when invalid access token ID is provided' do
jwt = build_jwt(double(id: 12345), user_id: personal_access_token.user_id)
get api('/packages/conan/v1/ping'), headers: build_token_auth_header(jwt.encoded)
expect(response).to have_gitlab_http_status(:unauthorized)
end
it 'responds with 401 Unauthorized when invalid user is provided' do
jwt = build_jwt(personal_access_token, user_id: 12345)
get api('/packages/conan/v1/ping'), headers: build_token_auth_header(jwt.encoded)
expect(response).to have_gitlab_http_status(:unauthorized)
end
it 'responds with 401 Unauthorized when the provided JWT is signed with different secret' do
jwt = build_jwt(personal_access_token, secret: SecureRandom.base64(32))
get api('/packages/conan/v1/ping'), headers: build_token_auth_header(jwt.encoded)
expect(response).to have_gitlab_http_status(:unauthorized)
end
it 'responds with 401 Unauthorized when invalid JWT is provided' do
get api('/packages/conan/v1/ping'), headers: build_token_auth_header('invalid-jwt')
expect(response).to have_gitlab_http_status(:unauthorized)
end
it 'responds with 401 Unauthorized when the job is not running' do
job.update!(status: :failed)
jwt = build_jwt_from_job(job)
get api('/packages/conan/v1/ping'), headers: build_token_auth_header(jwt.encoded)
expect(response).to have_gitlab_http_status(:unauthorized)
end
context 'packages feature disabled' do
it 'responds with 404 Not Found' do
stub_packages_setting(enabled: false)
get api('/packages/conan/v1/ping')
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
describe 'GET /api/v4/packages/conan/v1/conans/search' do
before do
project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
get api('/packages/conan/v1/conans/search'), headers: headers, params: params
end
subject { json_response['results'] }
context 'returns packages with a matching name' do
let(:params) { { q: package.conan_recipe } }
it { is_expected.to contain_exactly(package.conan_recipe) }
end
context 'returns packages using a * wildcard' do
let(:params) { { q: "#{package.name[0, 3]}*" } }
it { is_expected.to contain_exactly(package.conan_recipe) }
end
context 'does not return non-matching packages' do
let(:params) { { q: "foo" } }
it { is_expected.to be_blank }
end
end
describe 'GET /api/v4/packages/conan/v1/users/authenticate' do
subject { get api('/packages/conan/v1/users/authenticate'), headers: headers }
context 'when using invalid token' do
let(:auth_token) { 'invalid_token' }
it 'responds with 401' do
subject
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
context 'when valid JWT access token is provided' do
it 'responds with 200' do
subject
expect(response).to have_gitlab_http_status(:ok)
end
it 'token has valid validity time' do
freeze_time do
subject
payload = JSONWebToken::HMACToken.decode(
response.body, jwt_secret).first
expect(payload['access_token']).to eq(personal_access_token.id)
expect(payload['user_id']).to eq(personal_access_token.user_id)
duration = payload['exp'] - payload['iat']
expect(duration).to eq(1.hour)
end
end
end
context 'with valid job token' do
let(:auth_token) { job_token }
it 'responds with 200' do
subject
expect(response).to have_gitlab_http_status(:ok)
end
end
context 'with valid deploy token' do
let(:auth_token) { deploy_token.token }
it 'responds with 200' do
subject
expect(response).to have_gitlab_http_status(:ok)
end
end
end
describe 'GET /api/v4/packages/conan/v1/users/check_credentials' do
it 'responds with a 200 OK with PAT' do
get api('/packages/conan/v1/users/check_credentials'), headers: headers
expect(response).to have_gitlab_http_status(:ok)
end
context 'with job token' do
let(:auth_token) { job_token }
it 'responds with a 200 OK with job token' do
get api('/packages/conan/v1/users/check_credentials'), headers: headers
expect(response).to have_gitlab_http_status(:ok)
end
end
context 'with deploy token' do
let(:auth_token) { deploy_token.token }
it 'responds with a 200 OK with job token' do
get api('/packages/conan/v1/users/check_credentials'), headers: headers
expect(response).to have_gitlab_http_status(:ok)
end
end
it 'responds with a 401 Unauthorized when an invalid token is used' do
get api('/packages/conan/v1/users/check_credentials'), headers: build_token_auth_header('invalid-token')
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
shared_examples 'rejects invalid recipe' do
context 'with invalid recipe path' do
let(:recipe_path) { '../../foo++../..' }
it 'returns 400' do
subject
expect(response).to have_gitlab_http_status(:bad_request)
end
end
end
shared_examples 'rejects invalid file_name' do |invalid_file_name|
let(:file_name) { invalid_file_name }
context 'with invalid file_name' do
it 'returns 400' do
subject
expect(response).to have_gitlab_http_status(:bad_request)
end
end
end
shared_examples 'rejects recipe for invalid project' do
context 'with invalid recipe path' do
let(:recipe_path) { 'aa/bb/not-existing-project/ccc' }
it 'returns forbidden' do
subject
expect(response).to have_gitlab_http_status(:forbidden)
end
end
end
shared_examples 'rejects recipe for not found package' do
context 'with invalid recipe path' do
let(:recipe_path) do
'aa/bb/%{project}/ccc' % { project: ::Packages::Conan::Metadatum.package_username_from(full_path: project.full_path) }
end
it 'returns not found' do
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
shared_examples 'empty recipe for not found package' do
context 'with invalid recipe url' do
let(:recipe_path) do
'aa/bb/%{project}/ccc' % { project: ::Packages::Conan::Metadatum.package_username_from(full_path: project.full_path) }
end
it 'returns not found' do
allow(::Packages::Conan::PackagePresenter).to receive(:new)
.with(
nil,
user,
project,
any_args
).and_return(presenter)
allow(presenter).to receive(:recipe_snapshot) { {} }
allow(presenter).to receive(:package_snapshot) { {} }
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response.body).to eq("{}")
end
end
end
shared_examples 'not selecting a package with the wrong type' do
context 'with a nuget package with same name and version' do
let(:conan_username) { ::Packages::Conan::Metadatum.package_username_from(full_path: project.full_path) }
let(:wrong_package) { create(:nuget_package, name: "wrong", version: '1.0.0', project: project) }
let(:recipe_path) { "#{wrong_package.name}/#{wrong_package.version}/#{conan_username}/foo" }
it 'calls the presenter with a nil package' do
expect(::Packages::Conan::PackagePresenter).to receive(:new)
.with(nil, user, project, any_args)
subject
end
end
end
shared_examples 'recipe download_urls' do
let(:recipe_path) { package.conan_recipe_path }
it 'returns the download_urls for the recipe files' do
expected_response = {
'conanfile.py' => "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanfile.py",
'conanmanifest.txt' => "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt"
}
allow(presenter).to receive(:recipe_urls) { expected_response }
subject
expect(json_response).to eq(expected_response)
end
it_behaves_like 'not selecting a package with the wrong type'
end
shared_examples 'package download_urls' do
let(:recipe_path) { package.conan_recipe_path }
it 'returns the download_urls for the package files' do
expected_response = {
'conaninfo.txt' => "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conaninfo.txt",
'conanmanifest.txt' => "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conanmanifest.txt",
'conan_package.tgz' => "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conan_package.tgz"
}
allow(presenter).to receive(:package_urls) { expected_response }
subject
expect(json_response).to eq(expected_response)
end
it_behaves_like 'not selecting a package with the wrong type'
end
context 'recipe endpoints' do
let(:jwt) { build_jwt(personal_access_token) }
let(:headers) { build_token_auth_header(jwt.encoded) }
let(:conan_package_reference) { '123456789' }
let(:presenter) { double('::Packages::Conan::PackagePresenter') }
before do
allow(::Packages::Conan::PackagePresenter).to receive(:new)
.with(package, user, package.project, any_args)
.and_return(presenter)
end
shared_examples 'rejects invalid upload_url params' do
context 'with unaccepted json format' do
let(:params) { %w[foo bar] }
it 'returns 400' do
subject
expect(response).to have_gitlab_http_status(:bad_request)
end
end
end
shared_examples 'successful response when using Unicorn' do
context 'on Unicorn', :unicorn do
it 'returns successfully' do
subject
expect(response).to have_gitlab_http_status(:ok)
end
end
end
describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel' do
let(:recipe_path) { package.conan_recipe_path }
subject { get api("/packages/conan/v1/conans/#{recipe_path}"), headers: headers }
it_behaves_like 'rejects invalid recipe'
it_behaves_like 'rejects recipe for invalid project'
it_behaves_like 'empty recipe for not found package'
context 'with existing package' do
it 'returns a hash of files with their md5 hashes' do
expected_response = {
'conanfile.py' => 'md5hash1',
'conanmanifest.txt' => 'md5hash2'
}
allow(presenter).to receive(:recipe_snapshot) { expected_response }
subject
expect(json_response).to eq(expected_response)
end
end
end
describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference' do
let(:recipe_path) { package.conan_recipe_path }
subject { get api("/packages/conan/v1/conans/#{recipe_path}/packages/#{conan_package_reference}"), headers: headers }
it_behaves_like 'rejects invalid recipe'
it_behaves_like 'rejects recipe for invalid project'
it_behaves_like 'empty recipe for not found package'
context 'with existing package' do
it 'returns a hash of md5 values for the files' do
expected_response = {
'conaninfo.txt' => "md5hash1",
'conanmanifest.txt' => "md5hash2",
'conan_package.tgz' => "md5hash3"
}
allow(presenter).to receive(:package_snapshot) { expected_response }
subject
expect(json_response).to eq(expected_response)
end
end
end
describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/digest' do
subject { get api("/packages/conan/v1/conans/#{recipe_path}/digest"), headers: headers }
it_behaves_like 'rejects invalid recipe'
it_behaves_like 'rejects recipe for invalid project'
it_behaves_like 'recipe download_urls'
end
describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference/download_urls' do
subject { get api("/packages/conan/v1/conans/#{recipe_path}/packages/#{conan_package_reference}/download_urls"), headers: headers }
it_behaves_like 'rejects invalid recipe'
it_behaves_like 'rejects recipe for invalid project'
it_behaves_like 'package download_urls'
end
describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/download_urls' do
subject { get api("/packages/conan/v1/conans/#{recipe_path}/download_urls"), headers: headers }
it_behaves_like 'rejects invalid recipe'
it_behaves_like 'rejects recipe for invalid project'
it_behaves_like 'recipe download_urls'
end
describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference/digest' do
subject { get api("/packages/conan/v1/conans/#{recipe_path}/packages/#{conan_package_reference}/digest"), headers: headers }
it_behaves_like 'rejects invalid recipe'
it_behaves_like 'rejects recipe for invalid project'
it_behaves_like 'package download_urls'
end
describe 'POST /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/upload_urls' do
let(:recipe_path) { package.conan_recipe_path }
let(:params) do
{ 'conanfile.py': 24,
'conanmanifest.txt': 123 }
end
subject { post api("/packages/conan/v1/conans/#{recipe_path}/upload_urls"), params: params.to_json, headers: headers }
it_behaves_like 'rejects invalid recipe'
it_behaves_like 'rejects invalid upload_url params'
it_behaves_like 'successful response when using Unicorn'
it 'returns a set of upload urls for the files requested' do
subject
expected_response = {
'conanfile.py': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanfile.py",
'conanmanifest.txt': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt"
}
expect(response.body).to eq(expected_response.to_json)
end
context 'with conan_sources and conan_export files' do
let(:params) do
{ 'conan_sources.tgz': 345,
'conan_export.tgz': 234,
'conanmanifest.txt': 123 }
end
it 'returns upload urls for the additional files' do
subject
expected_response = {
'conan_sources.tgz': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conan_sources.tgz",
'conan_export.tgz': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conan_export.tgz",
'conanmanifest.txt': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt"
}
expect(response.body).to eq(expected_response.to_json)
end
end
context 'with an invalid file' do
let(:params) do
{ 'invalid_file.txt': 10,
'conanmanifest.txt': 123 }
end
it 'does not return the invalid file as an upload_url' do
subject
expected_response = {
'conanmanifest.txt': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt"
}
expect(response.body).to eq(expected_response.to_json)
end
end
end
describe 'POST /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference/upload_urls' do
let(:recipe_path) { package.conan_recipe_path }
let(:params) do
{ 'conaninfo.txt': 24,
'conanmanifest.txt': 123,
'conan_package.tgz': 523 }
end
subject { post api("/packages/conan/v1/conans/#{recipe_path}/packages/123456789/upload_urls"), params: params.to_json, headers: headers }
it_behaves_like 'rejects invalid recipe'
it_behaves_like 'rejects invalid upload_url params'
it_behaves_like 'successful response when using Unicorn'
it 'returns a set of upload urls for the files requested' do
expected_response = {
'conaninfo.txt': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conaninfo.txt",
'conanmanifest.txt': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conanmanifest.txt",
'conan_package.tgz': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conan_package.tgz"
}
subject
expect(response.body).to eq(expected_response.to_json)
end
context 'with invalid files' do
let(:params) do
{ 'conaninfo.txt': 24,
'invalid_file.txt': 10 }
end
it 'returns upload urls only for the valid requested files' do
expected_response = {
'conaninfo.txt': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conaninfo.txt"
}
subject
expect(response.body).to eq(expected_response.to_json)
end
end
end
describe 'DELETE /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel' do
let(:recipe_path) { package.conan_recipe_path }
subject { delete api("/packages/conan/v1/conans/#{recipe_path}"), headers: headers}
it_behaves_like 'rejects invalid recipe'
it 'returns unauthorized for users without valid permission' do
subject
expect(response).to have_gitlab_http_status(:forbidden)
end
context 'with delete permissions' do
before do
project.add_maintainer(user)
end
it_behaves_like 'a gitlab tracking event', described_class.name, 'delete_package'
it 'deletes a package' do
expect { subject }.to change { Packages::Package.count }.from(2).to(1)
end
end
end
end
context 'file endpoints' do
let(:jwt) { build_jwt(personal_access_token) }
let(:headers) { build_token_auth_header(jwt.encoded) }
let(:recipe_path) { package.conan_recipe_path }
shared_examples 'denies download with no token' do
context 'with no private token' do
let(:headers) { {} }
it 'returns 400' do
subject
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
end
shared_examples 'a public project with packages' do
it 'returns the file' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response.media_type).to eq('application/octet-stream')
end
end
shared_examples 'an internal project with packages' do
before do
project.team.truncate
project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
end
it_behaves_like 'denies download with no token'
it 'returns the file' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response.media_type).to eq('application/octet-stream')
end
end
shared_examples 'a private project with packages' do
before do
project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
end
it_behaves_like 'denies download with no token'
it 'returns the file' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response.media_type).to eq('application/octet-stream')
end
it 'denies download when not enough permissions' do
project.add_guest(user)
subject
expect(response).to have_gitlab_http_status(:forbidden)
end
end
shared_examples 'a project is not found' do
let(:recipe_path) { 'not/package/for/project' }
it 'returns forbidden' do
subject
expect(response).to have_gitlab_http_status(:forbidden)
end
end
describe 'GET /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/
:recipe_revision/export/:file_name' do
let(:recipe_file) { package.package_files.find_by(file_name: 'conanfile.py') }
let(:metadata) { recipe_file.conan_file_metadatum }
subject do
get api("/packages/conan/v1/files/#{recipe_path}/#{metadata.recipe_revision}/export/#{recipe_file.file_name}"),
headers: headers
end
it_behaves_like 'a public project with packages'
it_behaves_like 'an internal project with packages'
it_behaves_like 'a private project with packages'
it_behaves_like 'a project is not found'
end
describe 'GET /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/
:recipe_revision/package/:conan_package_reference/:package_revision/:file_name' do
let(:package_file) { package.package_files.find_by(file_name: 'conaninfo.txt') }
let(:metadata) { package_file.conan_file_metadatum }
subject do
get api("/packages/conan/v1/files/#{recipe_path}/#{metadata.recipe_revision}/package/#{metadata.conan_package_reference}/#{metadata.package_revision}/#{package_file.file_name}"),
headers: headers
end
it_behaves_like 'a public project with packages'
it_behaves_like 'an internal project with packages'
it_behaves_like 'a private project with packages'
it_behaves_like 'a project is not found'
context 'tracking the conan_package.tgz download' do
let(:package_file) { package.package_files.find_by(file_name: ::Packages::Conan::FileMetadatum::PACKAGE_BINARY) }
it_behaves_like 'a gitlab tracking event', described_class.name, 'pull_package'
end
end
end
context 'file uploads' do
let(:jwt) { build_jwt(personal_access_token) }
let(:workhorse_token) { JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256') }
let(:workhorse_header) { { 'GitLab-Workhorse' => '1.0', Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => workhorse_token } }
let(:headers_with_token) { build_token_auth_header(jwt.encoded).merge(workhorse_header) }
let(:recipe_path) { "foo/bar/#{project.full_path.tr('/', '+')}/baz"}
shared_examples 'uploads a package file' do
context 'file size above maximum limit' do
before do
params['file.size'] = project.actual_limits.conan_max_file_size + 1
end
it 'handles as a local file' do
subject
expect(response).to have_gitlab_http_status(:bad_request)
end
end
context 'with object storage disabled' do
context 'without a file from workhorse' do
let(:params) { { file: nil } }
it 'rejects the request' do
subject
expect(response).to have_gitlab_http_status(:bad_request)
end
end
context 'with a file' do
it_behaves_like 'package workhorse uploads'
end
context 'without a token' do
it 'rejects request without a token' do
headers_with_token.delete('HTTP_AUTHORIZATION')
subject
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
context 'when params from workhorse are correct' do
it 'creates package and stores package file' do
expect { subject }
.to change { project.packages.count }.by(1)
.and change { Packages::PackageFile.count }.by(1)
expect(response).to have_gitlab_http_status(:ok)
package_file = project.packages.last.package_files.reload.last
expect(package_file.file_name).to eq(params[:file].original_filename)
end
it "doesn't attempt to migrate file to object storage" do
expect(ObjectStorage::BackgroundMoveWorker).not_to receive(:perform_async)
subject
end
end
end
context 'with object storage enabled' do
context 'and direct upload enabled' do
let!(:fog_connection) do
stub_package_file_object_storage(direct_upload: true)
end
let(:tmp_object) do
fog_connection.directories.new(key: 'packages').files.create(
key: "tmp/uploads/#{file_name}",
body: 'content'
)
end
let(:fog_file) { fog_to_uploaded_file(tmp_object) }
['123123', '../../123123'].each do |remote_id|
context "with invalid remote_id: #{remote_id}" do
let(:params) do
{
file: fog_file,
'file.remote_id' => remote_id
}
end
it 'responds with status 403' do
subject
expect(response).to have_gitlab_http_status(:forbidden)
end
end
end
context 'with valid remote_id' do
let(:params) do
{
file: fog_file,
'file.remote_id' => file_name
}
end
it 'creates package and stores package file' do
expect { subject }
.to change { project.packages.count }.by(1)
.and change { Packages::PackageFile.count }.by(1)
expect(response).to have_gitlab_http_status(:ok)
package_file = project.packages.last.package_files.reload.last
expect(package_file.file_name).to eq(params[:file].original_filename)
expect(package_file.file.read).to eq('content')
end
end
end
it_behaves_like 'background upload schedules a file migration'
end
end
shared_examples 'workhorse authorization' do
it 'authorizes posting package with a valid token' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
end
it 'rejects request without a valid token' do
headers_with_token['HTTP_AUTHORIZATION'] = 'foo'
subject
expect(response).to have_gitlab_http_status(:unauthorized)
end
it 'rejects request without a valid permission' do
project.add_guest(user)
subject
expect(response).to have_gitlab_http_status(:forbidden)
end
it 'rejects requests that bypassed gitlab-workhorse' do
headers_with_token.delete(Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER)
subject
expect(response).to have_gitlab_http_status(:forbidden)
end
context 'when using remote storage' do
context 'when direct upload is enabled' do
before do
stub_package_file_object_storage(enabled: true, direct_upload: true)
end
it 'responds with status 200, location of package remote store and object details' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
expect(json_response).not_to have_key('TempPath')
expect(json_response['RemoteObject']).to have_key('ID')
expect(json_response['RemoteObject']).to have_key('GetURL')
expect(json_response['RemoteObject']).to have_key('StoreURL')
expect(json_response['RemoteObject']).to have_key('DeleteURL')
expect(json_response['RemoteObject']).not_to have_key('MultipartUpload')
end
end
context 'when direct upload is disabled' do
before do
stub_package_file_object_storage(enabled: true, direct_upload: false)
end
it 'handles as a local file' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
expect(json_response['TempPath']).to eq(::Packages::PackageFileUploader.workhorse_local_upload_path)
expect(json_response['RemoteObject']).to be_nil
end
end
end
end
describe 'PUT /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:file_name/authorize' do
let(:file_name) { 'conanfile.py' }
subject { put api("/packages/conan/v1/files/#{recipe_path}/0/export/#{file_name}/authorize"), headers: headers_with_token }
it_behaves_like 'rejects invalid recipe'
it_behaves_like 'rejects invalid file_name', 'conanfile.py.git%2fgit-upload-pack'
it_behaves_like 'workhorse authorization'
end
describe 'PUT /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:conan_package_reference/:package_revision/:file_name/authorize' do
let(:file_name) { 'conaninfo.txt' }
subject { put api("/packages/conan/v1/files/#{recipe_path}/0/package/123456789/0/#{file_name}/authorize"), headers: headers_with_token }
it_behaves_like 'rejects invalid recipe'
it_behaves_like 'rejects invalid file_name', 'conaninfo.txttest'
it_behaves_like 'workhorse authorization'
end
describe 'PUT /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:file_name' do
let(:file_name) { 'conanfile.py' }
let(:params) { { file: temp_file(file_name) } }
subject do
workhorse_finalize(
"/api/v4/packages/conan/v1/files/#{recipe_path}/0/export/#{file_name}",
method: :put,
file_key: :file,
params: params,
send_rewritten_field: true,
headers: headers_with_token
)
end
it_behaves_like 'rejects invalid recipe'
it_behaves_like 'rejects invalid file_name', 'conanfile.py.git%2fgit-upload-pack'
it_behaves_like 'uploads a package file'
end
describe 'PUT /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:conan_package_reference/:package_revision/:file_name' do
let(:file_name) { 'conaninfo.txt' }
let(:params) { { file: temp_file(file_name) } }
subject do
workhorse_finalize(
"/api/v4/packages/conan/v1/files/#{recipe_path}/0/package/123456789/0/#{file_name}",
method: :put,
file_key: :file,
params: params,
headers: headers_with_token,
send_rewritten_field: true
)
end
it_behaves_like 'rejects invalid recipe'
it_behaves_like 'rejects invalid file_name', 'conaninfo.txttest'
it_behaves_like 'uploads a package file'
context 'tracking the conan_package.tgz upload' do
let(:file_name) { ::Packages::Conan::FileMetadatum::PACKAGE_BINARY }
it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package'
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe API::ConanProjectPackages do
include_context 'conan api setup'
let(:project_id) { project.id }
describe 'GET /api/v4/projects/:id/packages/conan/v1/ping' do
let(:url) { "/projects/#{project.id}/packages/conan/v1/ping" }
it_behaves_like 'conan ping endpoint'
end
describe 'GET /api/v4/projects/:id/packages/conan/v1/conans/search' do
let(:url) { "/projects/#{project.id}/packages/conan/v1/conans/search" }
it_behaves_like 'conan search endpoint'
end
describe 'GET /api/v4/projects/:id/packages/conan/v1/users/authenticate' do
let(:url) { "/projects/#{project.id}/packages/conan/v1/users/authenticate" }
it_behaves_like 'conan authenticate endpoint'
end
describe 'GET /api/v4/projects/:id/packages/conan/v1/users/check_credentials' do
let(:url) { "/projects/#{project.id}/packages/conan/v1/users/check_credentials" }
it_behaves_like 'conan check_credentials endpoint'
end
context 'recipe endpoints' do
include_context 'conan recipe endpoints'
let(:url_prefix) { "#{Settings.gitlab.base_url}/api/v4/projects/#{project_id}" }
describe 'GET /api/v4/projects/:id/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel' do
let(:recipe_path) { package.conan_recipe_path }
let(:url) { "/projects/#{project_id}/packages/conan/v1/conans/#{recipe_path}" }
it_behaves_like 'recipe snapshot endpoint'
end
describe 'GET /api/v4/projects/:id/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference' do
let(:recipe_path) { package.conan_recipe_path }
let(:url) { "/projects/#{project_id}/packages/conan/v1/conans/#{recipe_path}/packages/#{conan_package_reference}" }
it_behaves_like 'package snapshot endpoint'
end
describe 'GET /api/v4/projects/:id/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/digest' do
subject { get api("/projects/#{project_id}/packages/conan/v1/conans/#{recipe_path}/digest"), headers: headers }
it_behaves_like 'recipe download_urls endpoint'
end
describe 'GET /api/v4/projects/:id/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference/download_urls' do
subject { get api("/projects/#{project_id}/packages/conan/v1/conans/#{recipe_path}/packages/#{conan_package_reference}/download_urls"), headers: headers }
it_behaves_like 'package download_urls endpoint'
end
describe 'GET /api/v4/projects/:id/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/download_urls' do
subject { get api("/projects/#{project_id}/packages/conan/v1/conans/#{recipe_path}/download_urls"), headers: headers }
it_behaves_like 'recipe download_urls endpoint'
end
describe 'GET /api/v4/projects/:id/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference/digest' do
subject { get api("/projects/#{project_id}/packages/conan/v1/conans/#{recipe_path}/packages/#{conan_package_reference}/digest"), headers: headers }
it_behaves_like 'package download_urls endpoint'
end
describe 'POST /api/v4/projects/:id/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/upload_urls' do
subject { post api("/projects/#{project_id}/packages/conan/v1/conans/#{recipe_path}/upload_urls"), params: params.to_json, headers: headers }
it_behaves_like 'recipe upload_urls endpoint'
end
describe 'POST /api/v4/projects/:id/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference/upload_urls' do
subject { post api("/projects/#{project_id}/packages/conan/v1/conans/#{recipe_path}/packages/123456789/upload_urls"), params: params.to_json, headers: headers }
it_behaves_like 'package upload_urls endpoint'
end
describe 'DELETE /api/v4/projects/:id/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel' do
subject { delete api("/projects/#{project_id}/packages/conan/v1/conans/#{recipe_path}"), headers: headers}
it_behaves_like 'delete package endpoint'
end
end
context 'file download endpoints' do
include_context 'conan file download endpoints'
describe 'GET /api/v4/projects/:id/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/
:recipe_revision/export/:file_name' do
subject do
get api("/projects/#{project_id}/packages/conan/v1/files/#{recipe_path}/#{metadata.recipe_revision}/export/#{recipe_file.file_name}"),
headers: headers
end
it_behaves_like 'recipe file download endpoint'
it_behaves_like 'project not found by project id'
end
describe 'GET /api/v4/projects/:id/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/
:recipe_revision/package/:conan_package_reference/:package_revision/:file_name' do
subject do
get api("/projects/#{project_id}/packages/conan/v1/files/#{recipe_path}/#{metadata.recipe_revision}/package/#{metadata.conan_package_reference}/#{metadata.package_revision}/#{package_file.file_name}"),
headers: headers
end
it_behaves_like 'package file download endpoint'
it_behaves_like 'project not found by project id'
end
end
context 'file upload endpoints' do
include_context 'conan file upload endpoints'
describe 'PUT /api/v4/projects/:id/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:file_name/authorize' do
let(:file_name) { 'conanfile.py' }
subject { put api("/projects/#{project_id}/packages/conan/v1/files/#{recipe_path}/0/export/#{file_name}/authorize"), headers: headers_with_token }
it_behaves_like 'workhorse authorize endpoint'
end
describe 'PUT /api/v4/projects/:id/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:conan_package_reference/:package_revision/:file_name/authorize' do
let(:file_name) { 'conaninfo.txt' }
subject { put api("/projects/#{project_id}/packages/conan/v1/files/#{recipe_path}/0/package/123456789/0/#{file_name}/authorize"), headers: headers_with_token }
it_behaves_like 'workhorse authorize endpoint'
end
describe 'PUT /api/v4/projects/:id/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:file_name' do
let(:url) { "/api/v4/projects/#{project_id}/packages/conan/v1/files/#{recipe_path}/0/export/#{file_name}" }
it_behaves_like 'workhorse recipe file upload endpoint'
end
describe 'PUT /api/v4/projects/:id/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:conan_package_reference/:package_revision/:file_name' do
let(:url) { "/api/v4/projects/#{project_id}/packages/conan/v1/files/#{recipe_path}/0/package/123456789/0/#{file_name}" }
it_behaves_like 'workhorse package file upload endpoint'
end
end
end
# frozen_string_literal: true
RSpec.shared_context 'conan api setup' do
include PackagesManagerApiSpecHelpers
include HttpBasicAuthHelpers
let(:package) { create(:conan_package) }
let_it_be(:personal_access_token) { create(:personal_access_token) }
let_it_be(:user) { personal_access_token.user }
let_it_be(:base_secret) { SecureRandom.base64(64) }
let_it_be(:job) { create(:ci_build, :running, user: user) }
let_it_be(:job_token) { job.token }
let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) }
let(:project) { package.project }
let(:auth_token) { personal_access_token.token }
let(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) }
let(:headers) do
{ 'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Basic.encode_credentials('foo', auth_token) }
end
let(:jwt_secret) do
OpenSSL::HMAC.hexdigest(
OpenSSL::Digest::SHA256.new,
base_secret,
Gitlab::ConanToken::HMAC_KEY
)
end
before do
project.add_developer(user)
allow(Settings).to receive(:attr_encrypted_db_key_base).and_return(base_secret)
end
end
RSpec.shared_context 'conan recipe endpoints' do
include PackagesManagerApiSpecHelpers
include HttpBasicAuthHelpers
let(:jwt) { build_jwt(personal_access_token) }
let(:headers) { build_token_auth_header(jwt.encoded) }
let(:conan_package_reference) { '123456789' }
let(:presenter) { double('::Packages::Conan::PackagePresenter') }
before do
allow(::Packages::Conan::PackagePresenter).to receive(:new)
.with(package, user, package.project, any_args)
.and_return(presenter)
end
end
RSpec.shared_context 'conan file download endpoints' do
include PackagesManagerApiSpecHelpers
include HttpBasicAuthHelpers
let(:jwt) { build_jwt(personal_access_token) }
let(:headers) { build_token_auth_header(jwt.encoded) }
let(:recipe_path) { package.conan_recipe_path }
let(:package_file) { package.package_files.find_by(file_name: 'conaninfo.txt') }
let(:recipe_file) { package.package_files.find_by(file_name: 'conanfile.py') }
let(:metadata) { package_file.conan_file_metadatum }
end
RSpec.shared_context 'conan file upload endpoints' do
include PackagesManagerApiSpecHelpers
include WorkhorseHelpers
include HttpBasicAuthHelpers
let(:jwt) { build_jwt(personal_access_token) }
let(:workhorse_token) { JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256') }
let(:workhorse_header) { { 'GitLab-Workhorse' => '1.0', Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => workhorse_token } }
let(:headers_with_token) { build_token_auth_header(jwt.encoded).merge(workhorse_header) }
let(:recipe_path) { "foo/bar/#{project.full_path.tr('/', '+')}/baz"}
end
# frozen_string_literal: true
RSpec.shared_examples 'conan ping endpoint' do
it 'responds with 401 Unauthorized when no token provided' do
get api(url)
expect(response).to have_gitlab_http_status(:unauthorized)
end
it 'responds with 200 OK when valid token is provided' do
jwt = build_jwt(personal_access_token)
get api(url), headers: build_token_auth_header(jwt.encoded)
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers['X-Conan-Server-Capabilities']).to eq("")
end
it 'responds with 200 OK when valid job token is provided' do
jwt = build_jwt_from_job(job)
get api(url), headers: build_token_auth_header(jwt.encoded)
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers['X-Conan-Server-Capabilities']).to eq("")
end
it 'responds with 200 OK when valid deploy token is provided' do
jwt = build_jwt_from_deploy_token(deploy_token)
get api(url), headers: build_token_auth_header(jwt.encoded)
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers['X-Conan-Server-Capabilities']).to eq("")
end
it 'responds with 401 Unauthorized when invalid access token ID is provided' do
jwt = build_jwt(double(id: 12345), user_id: personal_access_token.user_id)
get api(url), headers: build_token_auth_header(jwt.encoded)
expect(response).to have_gitlab_http_status(:unauthorized)
end
it 'responds with 401 Unauthorized when invalid user is provided' do
jwt = build_jwt(personal_access_token, user_id: 12345)
get api(url), headers: build_token_auth_header(jwt.encoded)
expect(response).to have_gitlab_http_status(:unauthorized)
end
it 'responds with 401 Unauthorized when the provided JWT is signed with different secret' do
jwt = build_jwt(personal_access_token, secret: SecureRandom.base64(32))
get api(url), headers: build_token_auth_header(jwt.encoded)
expect(response).to have_gitlab_http_status(:unauthorized)
end
it 'responds with 401 Unauthorized when invalid JWT is provided' do
get api(url), headers: build_token_auth_header('invalid-jwt')
expect(response).to have_gitlab_http_status(:unauthorized)
end
context 'packages feature disabled' do
it 'responds with 404 Not Found' do
stub_packages_setting(enabled: false)
get api(url)
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
RSpec.shared_examples 'conan search endpoint' do
before do
project.update_column(:visibility_level, Gitlab::VisibilityLevel::PUBLIC)
get api(url), headers: headers, params: params
end
subject { json_response['results'] }
context 'returns packages with a matching name' do
let(:params) { { q: package.conan_recipe } }
it { is_expected.to contain_exactly(package.conan_recipe) }
end
context 'returns packages using a * wildcard' do
let(:params) { { q: "#{package.name[0, 3]}*" } }
it { is_expected.to contain_exactly(package.conan_recipe) }
end
context 'does not return non-matching packages' do
let(:params) { { q: "foo" } }
it { is_expected.to be_blank }
end
end
RSpec.shared_examples 'conan authenticate endpoint' do
subject { get api(url), headers: headers }
context 'when using invalid token' do
let(:auth_token) { 'invalid_token' }
it 'responds with 401' do
subject
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
context 'when valid JWT access token is provided' do
it 'responds with 200' do
subject
expect(response).to have_gitlab_http_status(:ok)
end
it 'token has valid validity time' do
freeze_time do
subject
payload = JSONWebToken::HMACToken.decode(
response.body, jwt_secret).first
expect(payload['access_token']).to eq(personal_access_token.id)
expect(payload['user_id']).to eq(personal_access_token.user_id)
duration = payload['exp'] - payload['iat']
expect(duration).to eq(1.hour)
end
end
end
context 'with valid job token' do
let(:auth_token) { job_token }
it 'responds with 200' do
subject
expect(response).to have_gitlab_http_status(:ok)
end
end
context 'with valid deploy token' do
let(:auth_token) { deploy_token.token }
it 'responds with 200' do
subject
expect(response).to have_gitlab_http_status(:ok)
end
end
end
RSpec.shared_examples 'conan check_credentials endpoint' do
it 'responds with a 200 OK with PAT' do
get api(url), headers: headers
expect(response).to have_gitlab_http_status(:ok)
end
context 'with job token' do
let(:auth_token) { job_token }
it 'responds with a 200 OK with job token' do
get api(url), headers: headers
expect(response).to have_gitlab_http_status(:ok)
end
end
context 'with deploy token' do
let(:auth_token) { deploy_token.token }
it 'responds with a 200 OK with job token' do
get api(url), headers: headers
expect(response).to have_gitlab_http_status(:ok)
end
end
it 'responds with a 401 Unauthorized when an invalid token is used' do
get api(url), headers: build_token_auth_header('invalid-token')
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
RSpec.shared_examples 'rejects invalid recipe' do
context 'with invalid recipe path' do
let(:recipe_path) { '../../foo++../..' }
it 'returns 400' do
subject
expect(response).to have_gitlab_http_status(:bad_request)
end
end
end
RSpec.shared_examples 'rejects invalid file_name' do |invalid_file_name|
let(:file_name) { invalid_file_name }
context 'with invalid file_name' do
it 'returns 400' do
subject
expect(response).to have_gitlab_http_status(:bad_request)
end
end
end
RSpec.shared_examples 'rejects recipe for invalid project' do
context 'with invalid project' do
let(:recipe_path) { 'aa/bb/cc/dd' }
let(:project_id) { 9999 }
it_behaves_like 'not found request'
end
end
RSpec.shared_examples 'empty recipe for not found package' do
context 'with invalid recipe url' do
let(:recipe_path) do
'aa/bb/%{project}/ccc' % { project: ::Packages::Conan::Metadatum.package_username_from(full_path: project.full_path) }
end
it 'returns not found' do
allow(::Packages::Conan::PackagePresenter).to receive(:new)
.with(
nil,
user,
project,
any_args
).and_return(presenter)
allow(presenter).to receive(:recipe_snapshot) { {} }
allow(presenter).to receive(:package_snapshot) { {} }
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response.body).to eq("{}")
end
end
end
RSpec.shared_examples 'not selecting a package with the wrong type' do
context 'with a nuget package with same name and version' do
let(:conan_username) { ::Packages::Conan::Metadatum.package_username_from(full_path: project.full_path) }
let(:wrong_package) { create(:nuget_package, name: "wrong", version: '1.0.0', project: project) }
let(:recipe_path) { "#{wrong_package.name}/#{wrong_package.version}/#{conan_username}/foo" }
it 'calls the presenter with a nil package' do
expect(::Packages::Conan::PackagePresenter).to receive(:new)
.with(nil, user, project, any_args)
subject
end
end
end
RSpec.shared_examples 'recipe download_urls' do
let(:recipe_path) { package.conan_recipe_path }
it 'returns the download_urls for the recipe files' do
expected_response = {
'conanfile.py' => "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanfile.py",
'conanmanifest.txt' => "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt"
}
allow(presenter).to receive(:recipe_urls) { expected_response }
subject
expect(json_response).to eq(expected_response)
end
it_behaves_like 'not selecting a package with the wrong type'
end
RSpec.shared_examples 'package download_urls' do
let(:recipe_path) { package.conan_recipe_path }
it 'returns the download_urls for the package files' do
expected_response = {
'conaninfo.txt' => "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conaninfo.txt",
'conanmanifest.txt' => "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conanmanifest.txt",
'conan_package.tgz' => "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conan_package.tgz"
}
allow(presenter).to receive(:package_urls) { expected_response }
subject
expect(json_response).to eq(expected_response)
end
it_behaves_like 'not selecting a package with the wrong type'
end
RSpec.shared_examples 'rejects invalid upload_url params' do
context 'with unaccepted json format' do
let(:params) { %w[foo bar] }
it 'returns 400' do
subject
expect(response).to have_gitlab_http_status(:bad_request)
end
end
end
RSpec.shared_examples 'successful response when using Unicorn' do
context 'on Unicorn', :unicorn do
it 'returns successfully' do
subject
expect(response).to have_gitlab_http_status(:ok)
end
end
end
RSpec.shared_examples 'recipe snapshot endpoint' do
subject { get api(url), headers: headers }
it_behaves_like 'rejects invalid recipe'
it_behaves_like 'rejects recipe for invalid project'
it_behaves_like 'empty recipe for not found package'
context 'with existing package' do
it 'returns a hash of files with their md5 hashes' do
expected_response = {
'conanfile.py' => 'md5hash1',
'conanmanifest.txt' => 'md5hash2'
}
allow(presenter).to receive(:recipe_snapshot) { expected_response }
subject
expect(json_response).to eq(expected_response)
end
end
end
RSpec.shared_examples 'package snapshot endpoint' do
subject { get api(url), headers: headers }
it_behaves_like 'rejects invalid recipe'
it_behaves_like 'rejects recipe for invalid project'
it_behaves_like 'empty recipe for not found package'
context 'with existing package' do
it 'returns a hash of md5 values for the files' do
expected_response = {
'conaninfo.txt' => "md5hash1",
'conanmanifest.txt' => "md5hash2",
'conan_package.tgz' => "md5hash3"
}
allow(presenter).to receive(:package_snapshot) { expected_response }
subject
expect(json_response).to eq(expected_response)
end
end
end
RSpec.shared_examples 'recipe download_urls endpoint' do
it_behaves_like 'rejects invalid recipe'
it_behaves_like 'rejects recipe for invalid project'
it_behaves_like 'recipe download_urls'
end
RSpec.shared_examples 'package download_urls endpoint' do
it_behaves_like 'rejects invalid recipe'
it_behaves_like 'rejects recipe for invalid project'
it_behaves_like 'package download_urls'
end
RSpec.shared_examples 'recipe upload_urls endpoint' do
let(:recipe_path) { package.conan_recipe_path }
let(:params) do
{ 'conanfile.py': 24,
'conanmanifest.txt': 123 }
end
it_behaves_like 'rejects invalid recipe'
it_behaves_like 'rejects invalid upload_url params'
it_behaves_like 'successful response when using Unicorn'
it 'returns a set of upload urls for the files requested' do
subject
expected_response = {
'conanfile.py': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanfile.py",
'conanmanifest.txt': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt"
}
expect(response.body).to eq(expected_response.to_json)
end
context 'with conan_sources and conan_export files' do
let(:params) do
{ 'conan_sources.tgz': 345,
'conan_export.tgz': 234,
'conanmanifest.txt': 123 }
end
it 'returns upload urls for the additional files' do
subject
expected_response = {
'conan_sources.tgz': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conan_sources.tgz",
'conan_export.tgz': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conan_export.tgz",
'conanmanifest.txt': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt"
}
expect(response.body).to eq(expected_response.to_json)
end
end
context 'with an invalid file' do
let(:params) do
{ 'invalid_file.txt': 10,
'conanmanifest.txt': 123 }
end
it 'does not return the invalid file as an upload_url' do
subject
expected_response = {
'conanmanifest.txt': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt"
}
expect(response.body).to eq(expected_response.to_json)
end
end
end
RSpec.shared_examples 'package upload_urls endpoint' do
let(:recipe_path) { package.conan_recipe_path }
let(:params) do
{ 'conaninfo.txt': 24,
'conanmanifest.txt': 123,
'conan_package.tgz': 523 }
end
it_behaves_like 'rejects invalid recipe'
it_behaves_like 'rejects invalid upload_url params'
it_behaves_like 'successful response when using Unicorn'
it 'returns a set of upload urls for the files requested' do
expected_response = {
'conaninfo.txt': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conaninfo.txt",
'conanmanifest.txt': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conanmanifest.txt",
'conan_package.tgz': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conan_package.tgz"
}
subject
expect(response.body).to eq(expected_response.to_json)
end
context 'with invalid files' do
let(:params) do
{ 'conaninfo.txt': 24,
'invalid_file.txt': 10 }
end
it 'returns upload urls only for the valid requested files' do
expected_response = {
'conaninfo.txt': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conaninfo.txt"
}
subject
expect(response.body).to eq(expected_response.to_json)
end
end
end
RSpec.shared_examples 'delete package endpoint' do
let(:recipe_path) { package.conan_recipe_path }
it_behaves_like 'rejects invalid recipe'
it 'returns unauthorized for users without valid permission' do
subject
expect(response).to have_gitlab_http_status(:forbidden)
end
context 'with delete permissions' do
before do
project.add_maintainer(user)
end
it_behaves_like 'a gitlab tracking event', 'API::ConanPackages', 'delete_package'
it 'deletes a package' do
expect { subject }.to change { Packages::Package.count }.from(2).to(1)
end
end
end
RSpec.shared_examples 'denies download with no token' do
context 'with no private token' do
let(:headers) { {} }
it 'returns 400' do
subject
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
end
RSpec.shared_examples 'a public project with packages' do
it 'returns the file' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response.media_type).to eq('application/octet-stream')
end
end
RSpec.shared_examples 'an internal project with packages' do
before do
project.team.truncate
project.update_column(:visibility_level, Gitlab::VisibilityLevel::INTERNAL)
end
it_behaves_like 'denies download with no token'
it 'returns the file' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response.media_type).to eq('application/octet-stream')
end
end
RSpec.shared_examples 'a private project with packages' do
before do
project.update_column(:visibility_level, Gitlab::VisibilityLevel::PRIVATE)
end
it_behaves_like 'denies download with no token'
it 'returns the file' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response.media_type).to eq('application/octet-stream')
end
it 'denies download when not enough permissions' do
project.add_guest(user)
subject
expect(response).to have_gitlab_http_status(:forbidden)
end
end
RSpec.shared_examples 'not found request' do
it 'returns not found' do
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
RSpec.shared_examples 'recipe file download endpoint' do
it_behaves_like 'a public project with packages'
it_behaves_like 'an internal project with packages'
it_behaves_like 'a private project with packages'
end
RSpec.shared_examples 'package file download endpoint' do
it_behaves_like 'a public project with packages'
it_behaves_like 'an internal project with packages'
it_behaves_like 'a private project with packages'
context 'tracking the conan_package.tgz download' do
let(:package_file) { package.package_files.find_by(file_name: ::Packages::Conan::FileMetadatum::PACKAGE_BINARY) }
it_behaves_like 'a gitlab tracking event', 'API::ConanPackages', 'pull_package'
end
end
RSpec.shared_examples 'project not found by recipe' do
let(:recipe_path) { 'not/package/for/project' }
it_behaves_like 'not found request'
end
RSpec.shared_examples 'project not found by project id' do
let(:project_id) { 99999 }
it_behaves_like 'not found request'
end
RSpec.shared_examples 'workhorse authorize endpoint' do
it_behaves_like 'rejects invalid recipe'
it_behaves_like 'rejects invalid file_name', 'conanfile.py.git%2fgit-upload-pack'
it_behaves_like 'workhorse authorization'
end
RSpec.shared_examples 'workhorse recipe file upload endpoint' do
let(:file_name) { 'conanfile.py' }
let(:params) { { file: temp_file(file_name) } }
subject do
workhorse_finalize(
url,
method: :put,
file_key: :file,
params: params,
headers: headers_with_token,
send_rewritten_field: true
)
end
it_behaves_like 'rejects invalid recipe'
it_behaves_like 'rejects invalid file_name', 'conanfile.py.git%2fgit-upload-pack'
it_behaves_like 'uploads a package file'
end
RSpec.shared_examples 'workhorse package file upload endpoint' do
let(:file_name) { 'conaninfo.txt' }
let(:params) { { file: temp_file(file_name) } }
subject do
workhorse_finalize(
url,
method: :put,
file_key: :file,
params: params,
headers: headers_with_token,
send_rewritten_field: true
)
end
it_behaves_like 'rejects invalid recipe'
it_behaves_like 'rejects invalid file_name', 'conaninfo.txttest'
it_behaves_like 'uploads a package file'
context 'tracking the conan_package.tgz upload' do
let(:file_name) { ::Packages::Conan::FileMetadatum::PACKAGE_BINARY }
it_behaves_like 'a gitlab tracking event', 'API::ConanPackages', 'push_package'
end
end
RSpec.shared_examples 'uploads a package file' do
context 'file size above maximum limit' do
before do
params['file.size'] = project.actual_limits.conan_max_file_size + 1
end
it 'handles as a local file' do
subject
expect(response).to have_gitlab_http_status(:bad_request)
end
end
context 'with object storage disabled' do
context 'without a file from workhorse' do
let(:params) { { file: nil } }
it 'rejects the request' do
subject
expect(response).to have_gitlab_http_status(:bad_request)
end
end
context 'with a file' do
it_behaves_like 'package workhorse uploads'
end
context 'without a token' do
it 'rejects request without a token' do
headers_with_token.delete('HTTP_AUTHORIZATION')
subject
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
context 'when params from workhorse are correct' do
it 'creates package and stores package file' do
expect { subject }
.to change { project.packages.count }.by(1)
.and change { Packages::PackageFile.count }.by(1)
expect(response).to have_gitlab_http_status(:ok)
package_file = project.packages.last.package_files.reload.last
expect(package_file.file_name).to eq(params[:file].original_filename)
end
it "doesn't attempt to migrate file to object storage" do
expect(ObjectStorage::BackgroundMoveWorker).not_to receive(:perform_async)
subject
end
end
end
context 'with object storage enabled' do
context 'and direct upload enabled' do
let!(:fog_connection) do
stub_package_file_object_storage(direct_upload: true)
end
let(:tmp_object) do
fog_connection.directories.new(key: 'packages').files.create( # rubocop:disable Rails/SaveBang
key: "tmp/uploads/#{file_name}",
body: 'content'
)
end
let(:fog_file) { fog_to_uploaded_file(tmp_object) }
['123123', '../../123123'].each do |remote_id|
context "with invalid remote_id: #{remote_id}" do
let(:params) do
{
file: fog_file,
'file.remote_id' => remote_id
}
end
it 'responds with status 403' do
subject
expect(response).to have_gitlab_http_status(:forbidden)
end
end
end
context 'with valid remote_id' do
let(:params) do
{
file: fog_file,
'file.remote_id' => file_name
}
end
it 'creates package and stores package file' do
expect { subject }
.to change { project.packages.count }.by(1)
.and change { Packages::PackageFile.count }.by(1)
expect(response).to have_gitlab_http_status(:ok)
package_file = project.packages.last.package_files.reload.last
expect(package_file.file_name).to eq(params[:file].original_filename)
expect(package_file.file.read).to eq('content')
end
end
end
it_behaves_like 'background upload schedules a file migration'
end
end
RSpec.shared_examples 'workhorse authorization' do
it 'authorizes posting package with a valid token' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
end
it 'rejects request without a valid token' do
headers_with_token['HTTP_AUTHORIZATION'] = 'foo'
subject
expect(response).to have_gitlab_http_status(:unauthorized)
end
it 'rejects request without a valid permission' do
project.add_guest(user)
subject
expect(response).to have_gitlab_http_status(:forbidden)
end
it 'rejects requests that bypassed gitlab-workhorse' do
headers_with_token.delete(Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER)
subject
expect(response).to have_gitlab_http_status(:forbidden)
end
context 'when using remote storage' do
context 'when direct upload is enabled' do
before do
stub_package_file_object_storage(enabled: true, direct_upload: true)
end
it 'responds with status 200, location of package remote store and object details' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
expect(json_response).not_to have_key('TempPath')
expect(json_response['RemoteObject']).to have_key('ID')
expect(json_response['RemoteObject']).to have_key('GetURL')
expect(json_response['RemoteObject']).to have_key('StoreURL')
expect(json_response['RemoteObject']).to have_key('DeleteURL')
expect(json_response['RemoteObject']).not_to have_key('MultipartUpload')
end
end
context 'when direct upload is disabled' do
before do
stub_package_file_object_storage(enabled: true, direct_upload: false)
end
it 'handles as a local file' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
expect(json_response['TempPath']).to eq(::Packages::PackageFileUploader.workhorse_local_upload_path)
expect(json_response['RemoteObject']).to be_nil
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