Commit 1a0ec496 authored by Ethan Reesor's avatar Ethan Reesor Committed by Ethan Reesor

Improve tests and apply suggestions for Go proxy

See !27746

- Fix rubocop disable comments
- Move `before` block to before tests
- Correct rubocop alerts due to new rules
- Use shared examples to clarify Go proxy spec
- Enable HTTP Basic authentication for Go proxy
  + Support both HTTP basic and normal token header/query var
  + Remove custom `find_project!` helper and use basic auth helpers
- Validate GoModuleVersion type attribute
- Implement testing factories
- Implement specs for untested new classes
- Add a Settings helper for Go URLs
parent e1009191
......@@ -66,6 +66,12 @@ class Settings < Settingslogic
(base_url(gitlab) + [gitlab.relative_url_root]).join('')
end
def build_gitlab_go_url
# "Go package paths are not URLs, and do not include port numbers"
# https://github.com/golang/go/issues/38213#issuecomment-607851460
"#{gitlab.host}#{gitlab.relative_url_root}"
end
def kerberos_protocol
kerberos.https ? "https" : "http"
end
......
......@@ -5,6 +5,8 @@ module Packages
class ModuleFinder
include ::API::Helpers::Packages::Go::ModuleHelpers
GITLAB_GO_URL = (Settings.build_gitlab_go_url + '/').freeze
attr_reader :project, :module_name
def initialize(project, module_name)
......@@ -14,23 +16,17 @@ module Packages
@module_name = module_name
end
# rubocop: disable CodeReuse/ActiveRecord
def execute
return if @module_name.blank?
if @module_name == package_base
Packages::GoModule.new(@project, @module_name, '')
elsif @module_name.start_with?(package_base + '/')
Packages::GoModule.new(@project, @module_name, @module_name[(package_base.length + 1)..])
else
nil
end
end
return if @module_name.blank? || !@module_name.start_with?(GITLAB_GO_URL)
private
module_path = @module_name[GITLAB_GO_URL.length..].split('/')
project_path = project.full_path.split('/')
return unless module_path.take(project_path.length) == project_path
def package_base
@package_base ||= Gitlab::Routing.url_helpers.project_url(@project).split('://', 2)[1]
Packages::GoModule.new(@project, @module_name, module_path.drop(project_path.length).join('/'))
end
# rubocop: enable CodeReuse/ActiveRecord
end
end
end
......@@ -21,14 +21,10 @@ module Packages
def find(target)
case target
when String
unless pseudo_version? target
return mod.versions.filter { |v| v.name == target }.first
end
begin
if pseudo_version? target
find_pseudo_version target
rescue ArgumentError
nil
else
mod.versions.find { |v| v.name == target }
end
when Gitlab::Git::Ref
......
......@@ -3,6 +3,8 @@
class Packages::GoModuleVersion
include ::API::Helpers::Packages::Go::ModuleHelpers
VALID_TYPES = %i[ref commit pseudo].freeze
attr_reader :mod, :type, :ref, :commit
delegate :major, to: :@semver, allow_nil: true
......@@ -12,6 +14,17 @@ class Packages::GoModuleVersion
delegate :build, to: :@semver, allow_nil: true
def initialize(mod, type, commit, name: nil, semver: nil, ref: nil)
raise ArgumentError.new("invalid type '#{type}'") unless VALID_TYPES.include? type
raise ArgumentError.new("mod is required") unless mod
raise ArgumentError.new("commit is required") unless commit
if type == :ref
raise ArgumentError.new("ref is required") unless ref
elsif type == :pseudo
raise ArgumentError.new("name is required") unless name
raise ArgumentError.new("semver is required") unless semver
end
@mod = mod
@type = type
@commit = commit
......
# frozen_string_literal: true
class Packages::SemVer
# basic semver, but bounded (^expr$)
PATTERN = /\A(v?)(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([-.a-z0-9]+))?(?:\+([-.a-z0-9]+))?\z/i.freeze
attr_accessor :major, :minor, :patch, :prerelease, :build
def initialize(major = 0, minor = 0, patch = 0, prerelease = nil, build = nil, prefixed: false)
@major = major
@minor = minor
@patch = patch
@prerelease = prerelease
@build = build
@prefixed = prefixed
end
def prefixed?
@prefixed
end
def ==(other)
self.class == other.class &&
self.major == other.major &&
self.minor == other.minor &&
self.patch == other.patch &&
self.prerelease == other.prerelease &&
self.build == other.build
end
def to_s
s = "#{prefixed? ? 'v' : ''}#{major || 0}.#{minor || 0}.#{patch || 0}"
s += "-#{prerelease}" if prerelease
s += "+#{build}" if build
s
end
def self.match(str, prefixed: false)
m = PATTERN.match(str)
return unless m
return if prefixed == m[1].empty?
m
end
def self.match?(str, prefixed: false)
!match(str, prefixed: prefixed).nil?
end
def self.parse(str, prefixed: false)
m = match str, prefixed: prefixed
return unless m
new(m[2].to_i, m[3].to_i, m[4].to_i, m[5], m[6], prefixed: prefixed)
end
end
# frozen_string_literal: true
module API
class GoProxy < Grape::API
helpers ::API::Helpers::PackagesHelpers
helpers ::API::Helpers::PackagesManagerClientsHelpers
helpers ::API::Helpers::Packages::BasicAuthHelpers
helpers ::API::Helpers::Packages::Go::ModuleHelpers
# basic semver, except case encoded (A => !a)
......@@ -12,15 +13,27 @@ module API
before { require_packages_enabled! }
helpers do
def case_decode(str)
str.gsub(/![[:alpha:]]/) { |s| s[1..].upcase }
# support personal access tokens for HTTP Basic in addition to the usual methods
def find_personal_access_token
pa = find_personal_access_token_from_http_basic_auth
return pa if pa
# copied from Gitlab::Auth::AuthFinders
token =
current_request.params[::Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_PARAM].presence ||
current_request.env[::Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_HEADER].presence ||
parsed_oauth_token
return unless token
# Expiration, revocation and scopes are verified in `validate_access_token!`
PersonalAccessToken.find_by_token(token) || raise(::Gitlab::Auth::UnauthorizedError)
end
def find_module
module_name = case_decode params[:module_name]
bad_request!('Module Name') if module_name.blank?
mod = ::Packages::Go::ModuleFinder.new(user_project, module_name).execute
mod = ::Packages::Go::ModuleFinder.new(authorized_user_project, module_name).execute
not_found! unless mod
......@@ -29,25 +42,14 @@ module API
def find_version
module_version = case_decode params[:module_version]
ver = ::Packages::Go::VersionFinder.new(find_module).find(module_version)
ver = find_module.find_version(module_version)
not_found! unless ver&.valid?
ver
end
def find_project!(id)
project = find_project(id)
ability = job_token_authentication? ? :build_read_project : :read_project
if can?(current_user, ability, project)
project
elsif current_user.nil?
unauthorized!
else
not_found!('Project')
end
rescue ArgumentError
not_found!
end
end
......@@ -58,8 +60,8 @@ module API
route_setting :authentication, job_token_allowed: true
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
before do
authorize_read_package!
authorize_packages_feature!
authorize_read_package!(authorized_user_project)
authorize_packages_feature!(authorized_user_project)
end
namespace ':id/packages/go/*module_name/@v' do
......
......@@ -8,9 +8,6 @@ module API
# basic semver regex
SEMVER_REGEX = /v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([-.a-z0-9]+))?(?:\+([-.a-z0-9]+))?/i.freeze
# basic semver, but bounded (^expr$)
SEMVER_TAG_REGEX = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([-.a-z0-9]+))?(?:\+([-.a-z0-9]+))?$/i.freeze
# semver, but the prerelease component follows a specific format
PSEUDO_VERSION_REGEX = /^v\d+\.(0\.0-|\d+\.\d+-([^+]*\.)?0\.)\d{14}-[A-Za-z0-9]+(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/i.freeze
......@@ -25,23 +22,15 @@ module API
def semver?(tag)
return false if tag.dereferenced_target.nil?
SEMVER_TAG_REGEX.match?(tag.name)
::Packages::SemVer.match?(tag.name, prefixed: true)
end
def pseudo_version?(str)
SEMVER_TAG_REGEX.match?(str) && PSEUDO_VERSION_REGEX.match?(str)
::Packages::SemVer.match?(str, prefixed: true) && PSEUDO_VERSION_REGEX.match?(str)
end
def parse_semver(str)
m = SEMVER_TAG_REGEX.match(str)
return unless m
OpenStruct.new(
major: m[1].to_i,
minor: m[2].to_i,
patch: m[3].to_i,
prerelease: m[4],
build: m[5])
::Packages::SemVer.parse(str, prefixed: true)
end
end
end
......
# frozen_string_literal: true
def get_result(op, ret)
raise "#{op} failed: #{ret}" unless ret[:status] == :success
ret[:result]
end
FactoryBot.define do
factory :go_module_commit, class: 'Commit' do
skip_create
transient do
project { raise ArgumentError.new("project is required") }
service { raise ArgumentError.new("this factory cannot be used without specifying a trait") }
tag { nil }
tag_message { nil }
commit do
r = service.execute
raise "operation failed: #{r}" unless r[:status] == :success
commit = project.repository.commit_by(oid: r[:result])
if tag
r = Tags::CreateService.new(project, project.owner).execute(tag, commit.sha, tag_message)
raise "operation failed: #{r}" unless r[:status] == :success
end
commit
end
end
initialize_with do
commit
end
trait :files do
transient do
files { raise ArgumentError.new("files is required") }
message { 'Add files' }
end
service do
Files::MultiService.new(
project,
project.owner,
commit_message: message,
start_branch: project.repository.root_ref || 'master',
branch_name: project.repository.root_ref || 'master',
actions: files.map do |path, content|
{ action: :create, file_path: path, content: content }
end
)
end
end
trait :package do
transient do
path { raise ArgumentError.new("path is required") }
message { 'Add package' }
end
service do
Files::MultiService.new(
project,
project.owner,
commit_message: message,
start_branch: project.repository.root_ref || 'master',
branch_name: project.repository.root_ref || 'master',
actions: [
{ action: :create, file_path: path + '/b.go', content: "package b\nfunc Bye() { println(\"Goodbye world!\") }\n" }
]
)
end
end
trait :module do
transient do
name { nil }
message { 'Add module' }
end
service do
port = ::Gitlab.config.gitlab.port
host = ::Gitlab.config.gitlab.host
domain = case port when 80, 443 then host else "#{host}:#{port}" end
url = "#{domain}/#{project.path_with_namespace}"
if name.nil?
path = ''
else
url += '/' + name
path = name + '/'
end
Files::MultiService.new(
project,
project.owner,
commit_message: message,
start_branch: project.repository.root_ref || 'master',
branch_name: project.repository.root_ref || 'master',
actions: [
{ action: :create, file_path: path + 'go.mod', content: "module #{url}\n" },
{ action: :create, file_path: path + 'a.go', content: "package a\nfunc Hi() { println(\"Hello world!\") }\n" }
]
)
end
end
end
end
# frozen_string_literal: true
def get_result(op, ret)
raise "#{op} failed: #{ret}" unless ret[:status] == :success
ret[:result]
end
FactoryBot.define do
factory :go_module_version, class: 'Packages::GoModuleVersion' do
skip_create
initialize_with do
p = attributes[:params]
s = Packages::SemVer.parse(p.semver, prefixed: true)
raise ArgumentError.new("invalid sematic version: '#{p.semver}''") if !s && p.semver
new(p.mod, p.type, p.commit, name: p.name, semver: s, ref: p.ref)
end
mod { go_module }
type { :commit }
commit { raise ArgumentError.new("commit is required") }
name { nil }
semver { nil }
ref { nil }
params { OpenStruct.new(mod: mod, type: type, commit: commit, name: name, semver: semver, ref: ref) }
trait :tagged do
name { raise ArgumentError.new("name is required") }
ref { mod.project.repository.find_tag(name) }
commit { ref.dereferenced_target }
params { OpenStruct.new(mod: mod, type: :ref, commit: commit, semver: name, ref: ref) }
end
trait :pseudo do
transient do
prefix { raise ArgumentError.new("prefix is required") }
end
type { :pseudo }
name { "#{prefix}#{commit.committed_date.strftime('%Y%m%d%H%M%S')}-#{commit.sha[0..11]}" }
params { OpenStruct.new(mod: mod, type: :pseudo, commit: commit, name: name, semver: name) }
end
end
end
# frozen_string_literal: true
FactoryBot.define do
factory :go_module, class: 'Packages::GoModule' do
initialize_with { new(attributes[:project], attributes[:name], attributes[:path]) }
skip_create
project
path { '' }
name { "#{Settings.build_gitlab_go_url}/#{project.full_path}#{path.empty? ? '' : path}" }
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Packages::Go::ModuleFinder do
let_it_be(:project) { create :project }
let_it_be(:other_project) { create :project }
describe '#execute' do
context 'with module name equal to project name' do
let(:finder) { described_class.new(project, base_url(project)) }
it 'returns a module with empty path' do
mod = finder.execute
expect(mod).not_to be_nil
expect(mod.path).to eq('')
end
end
context 'with module name starting with project name and slash' do
let(:finder) { described_class.new(project, base_url(project) + '/mod') }
it 'returns a module with non-empty path' do
mod = finder.execute
expect(mod).not_to be_nil
expect(mod.path).to eq('mod')
end
end
context 'with a module name not equal to and not starting with project name' do
let(:finder) { described_class.new(project, base_url(other_project)) }
it 'returns nil' do
expect(finder.execute).to be_nil
end
end
end
def base_url(project)
"#{Settings.build_gitlab_go_url}/#{project.full_path}"
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Packages::Go::VersionFinder do
let_it_be(:user) { create :user }
let_it_be(:project) { create :project_empty_repo, creator: user, path: 'my-go-lib' }
let(:finder) { described_class.new mod }
before :all do
create :go_module_commit, :files, project: project, tag: 'v1.0.0', files: { 'README.md' => 'Hi' }
create :go_module_commit, :module, project: project, tag: 'v1.0.1'
create :go_module_commit, :package, project: project, tag: 'v1.0.2', path: 'pkg'
create :go_module_commit, :module, project: project, tag: 'v1.0.3', name: 'mod'
create :go_module_commit, :files, project: project, tag: 'c1', files: { 'y.go' => "package a\n" }
create :go_module_commit, :module, project: project, tag: 'c2', name: 'v2'
create :go_module_commit, :files, project: project, tag: 'v2.0.0', files: { 'v2/x.go' => "package a\n" }
end
shared_examples '#execute' do |*expected|
it "returns #{expected.empty? ? 'nothing' : expected.join(', ')}" do
actual = finder.execute.map { |x| x.name }
expect(actual.to_set).to eq(expected.to_set)
end
end
shared_examples '#find with an invalid argument' do |message|
it "raises an argument exception: #{message}" do
expect { finder.find(target) }.to raise_error(ArgumentError, message)
end
end
describe '#execute' do
context 'for the root module' do
let(:mod) { create :go_module, project: project }
it_behaves_like '#execute', 'v1.0.1', 'v1.0.2', 'v1.0.3'
end
context 'for the package' do
let(:mod) { create :go_module, project: project, path: '/pkg' }
it_behaves_like '#execute'
end
context 'for the submodule' do
let(:mod) { create :go_module, project: project, path: '/mod' }
it_behaves_like '#execute', 'v1.0.3'
end
context 'for the root module v2' do
let(:mod) { create :go_module, project: project, path: '/v2' }
it_behaves_like '#execute', 'v2.0.0'
end
end
describe '#find' do
let(:mod) { create :go_module, project: project }
context 'with a ref' do
it 'returns a ref version' do
ref = project.repository.find_branch 'master'
v = finder.find(ref)
expect(v.type).to eq(:ref)
expect(v.ref).to eq(ref)
end
end
context 'with a semver tag' do
it 'returns a version with a semver' do
v = finder.find(project.repository.find_tag('v1.0.0'))
expect(v.major).to eq(1)
expect(v.minor).to eq(0)
expect(v.patch).to eq(0)
expect(v.prerelease).to be_nil
expect(v.build).to be_nil
end
end
context 'with a semver tag string' do
it 'returns a version with a semver' do
v = finder.find('v1.0.1')
expect(v.major).to eq(1)
expect(v.minor).to eq(0)
expect(v.patch).to eq(1)
expect(v.prerelease).to be_nil
expect(v.build).to be_nil
end
end
context 'with a commit' do
it 'retruns a commit version' do
v = finder.find(project.repository.head_commit)
expect(v.type).to eq(:commit)
end
end
context 'with a pseudo-version' do
it 'returns a pseudo version' do
commit = project.repository.head_commit
pseudo = "v0.0.0-#{commit.committed_date.strftime('%Y%m%d%H%M%S')}-#{commit.sha[0..11]}"
v = finder.find(pseudo)
expect(v.type).to eq(:pseudo)
expect(v.commit).to eq(commit)
expect(v.name).to eq(pseudo)
end
end
context 'with a string that is not a semantic version' do
it 'returns nil' do
expect(finder.find('not-a-semver')).to be_nil
end
end
context 'with a pseudo-version that does not reference a commit' do
it_behaves_like '#find with an invalid argument', 'invalid pseudo-version: unknown commit' do
let(:commit) { project.repository.head_commit }
let(:target) { "v0.0.0-#{commit.committed_date.strftime('%Y%m%d%H%M%S')}-#{'0' * 12}" }
end
end
context 'with a pseudo-version with a short sha' do
it_behaves_like '#find with an invalid argument', 'invalid pseudo-version: revision is shorter than canonical' do
let(:commit) { project.repository.head_commit }
let(:target) { "v0.0.0-#{commit.committed_date.strftime('%Y%m%d%H%M%S')}-#{commit.sha[0..10]}" }
end
end
context 'with a pseudo-version with an invalid timestamp' do
it_behaves_like '#find with an invalid argument', 'invalid pseudo-version: does not match version-control timestamp' do
let(:commit) { project.repository.head_commit }
let(:target) { "v0.0.0-#{'0' * 14}-#{commit.sha[0..11]}" }
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Packages::GoModule, type: :model do
describe '#path_valid?' do
context 'with root path' do
let_it_be(:package) { create(:go_module) }
context 'with major version 0' do
it('returns true') { expect(package.path_valid?(0)).to eq(true) }
end