diff --git a/lib/travis/api/v3.rb b/lib/travis/api/v3.rb index d2c19859..26fa774f 100644 --- a/lib/travis/api/v3.rb +++ b/lib/travis/api/v3.rb @@ -24,15 +24,16 @@ module Travis load_dir("#{__dir__}/v3/extensions") load_dir("#{__dir__}/v3") - ClientError = Error .create(status: 400) - NotFound = ClientError .create(:resource, status: 404, template: '%s not found (or insufficient access)') - EntityMissing = NotFound .create(type: 'not_found') - WrongCredentials = ClientError .create('access denied', status: 403) - LoginRequired = ClientError .create('login required', status: 403) - InsufficientAccess = ClientError .create(status: 403) - WrongParams = ClientError .create('wrong parameters') - ServerError = Error .create(status: 500) - NotImplemented = ServerError .create('request not (yet) implemented', status: 501) + ClientError = Error .create(status: 400) + NotFound = ClientError .create(:resource, status: 404, template: '%s not found (or insufficient access)') + EntityMissing = NotFound .create(type: 'not_found') + WrongCredentials = ClientError .create('access denied', status: 403) + LoginRequired = ClientError .create('login required', status: 403) + InsufficientAccess = ClientError .create(status: 403) + PushAccessRequired = InsufficientAccess .create('push access required') + WrongParams = ClientError .create('wrong parameters') + ServerError = Error .create(status: 500) + NotImplemented = ServerError .create('request not (yet) implemented', status: 501) end end end diff --git a/lib/travis/api/v3/access_control/application.rb b/lib/travis/api/v3/access_control/application.rb index f060e2dd..80e26d53 100644 --- a/lib/travis/api/v3/access_control/application.rb +++ b/lib/travis/api/v3/access_control/application.rb @@ -16,8 +16,6 @@ module Travis::API::V3 full_access? or !!user end - protected - def full_access? config.full_access end diff --git a/lib/travis/api/v3/access_control/generic.rb b/lib/travis/api/v3/access_control/generic.rb index a8e9d2e0..d04b01d6 100644 --- a/lib/travis/api/v3/access_control/generic.rb +++ b/lib/travis/api/v3/access_control/generic.rb @@ -11,6 +11,10 @@ module Travis::API::V3 full_access? or dispatch(object) end + def writable?(object) + full_access? or dispatch(object) + end + def user end @@ -18,6 +22,10 @@ module Travis::API::V3 false end + def full_access? + false + end + protected def build_visible?(build) @@ -41,10 +49,6 @@ module Travis::API::V3 false end - def full_access? - false - end - def public_api? !Travis.config.private_api end diff --git a/lib/travis/api/v3/access_control/user.rb b/lib/travis/api/v3/access_control/user.rb index ecc54315..5dbe81bd 100644 --- a/lib/travis/api/v3/access_control/user.rb +++ b/lib/travis/api/v3/access_control/user.rb @@ -17,6 +17,10 @@ module Travis::API::V3 protected + def repository_writable?(repository) + permission?(:push, repository) + end + def private_repository_visible?(repository) permission?(:pull, repository) end diff --git a/lib/travis/api/v3/queries/request.rb b/lib/travis/api/v3/queries/request.rb new file mode 100644 index 00000000..60c551b9 --- /dev/null +++ b/lib/travis/api/v3/queries/request.rb @@ -0,0 +1,18 @@ +module Travis::API::V3 + class Queries::Request < Query + params :message, :branch, :config, prefix: :request + + def schedule(repository, user) + raise ServerError, 'repository does not have a github_id'.freeze unless repository.github_id + raise WrongParams, 'missing user'.freeze unless user and user.id + + perform_async(:build_request, type: 'api'.freeze, credentials: {}, payload: { + repository: { id: repository.github_id }, + user: { id: user.id }, + message: message, + branch: branch || repository.default_branch_name, + config: config || {} + }) + end + end +end diff --git a/lib/travis/api/v3/queries/requests.rb b/lib/travis/api/v3/queries/requests.rb index 3ccc1c94..05d01869 100644 --- a/lib/travis/api/v3/queries/requests.rb +++ b/lib/travis/api/v3/queries/requests.rb @@ -1,14 +1,6 @@ module Travis::API::V3 class Queries::Requests < Query - def schedule_for(repository) - perform_async(:build_request, type: 'api'.freeze, payload: payload, credentials: {}) - end - def find(repository) end - - def payload - raise NotImplementedError - end end end diff --git a/lib/travis/api/v3/queries/user.rb b/lib/travis/api/v3/queries/user.rb new file mode 100644 index 00000000..af067d19 --- /dev/null +++ b/lib/travis/api/v3/queries/user.rb @@ -0,0 +1,10 @@ +module Travis::API::V3 + class Queries::User < Query + params :id + + def find + return Models::User.find_by_id(id) if id + raise WrongParams, 'missing user.id'.freeze + end + end +end diff --git a/lib/travis/api/v3/query.rb b/lib/travis/api/v3/query.rb index 48989490..52aca622 100644 --- a/lib/travis/api/v3/query.rb +++ b/lib/travis/api/v3/query.rb @@ -10,12 +10,12 @@ module Travis::API::V3 return @%s if defined? @%s return @%s = @params['%s.%s'.freeze] if @params.include? '%s.%s'.freeze return @%s = @params['%s'.freeze]['%s'.freeze] if @params.include? '%s'.freeze and @params['%s'.freeze].is_a? Hash - return @%s = @params['%s'.freeze] if @params['@type'.freeze].nil? or @params['@type'.freeze] == '%s'.freeze + return @%s = @params['%s'.freeze] if (@params['@type'.freeze] || @main_type) == '%s'.freeze @%s = nil end def %s! - %s or raise WrongParams, 'missing %s.%s'.freeze, missing_field: '%s.%s'.freeze + %s or raise WrongParams, 'missing %s.%s'.freeze end RUBY @@ -24,13 +24,14 @@ module Travis::API::V3 list.each { |e| class_eval(@@params_accessor % { name: e, prefix: prefix }) } end - attr_reader :params + attr_reader :params, :main_type - def initialize(params) - @params = params + def initialize(params, main_type) + @params = params + @main_type = main_type.to_s end - def perform_async(worker, *args) + def perform_async(identifier, *args) class_name, queue, client = @@sidekiq_cache[identifier] ||= [ "Travis::Sidekiq::#{identifier.to_s.camelcase}".freeze, identifier.to_s.pluralize.freeze diff --git a/lib/travis/api/v3/renderer.rb b/lib/travis/api/v3/renderer.rb index 3747cd61..7f724b42 100644 --- a/lib/travis/api/v3/renderer.rb +++ b/lib/travis/api/v3/renderer.rb @@ -1,5 +1,8 @@ module Travis::API::V3 module Renderer + PRIMITIVE = [String, Symbol, Numeric, true, false, nil] + private_constant :PRIMITIVE + EXPANDER_CACHE = Tool::ThreadLocal.new private_constant :EXPANDER_CACHE @@ -31,6 +34,21 @@ module Travis::API::V3 expander.call(args) end + def render_model(model, type: model.class.name[/[^:]+$/].to_sym, mode: :minimal, **options) + Renderer[type].render(model, mode, **options) + end + + def render_value(value, **options) + case value + when Hash then value.map { |k, v| [k, render_value(v)] }.to_h + when Array then value.map { |v | render_value(v) } + when *PRIMITIVE then value + when Time then value.strftime('%Y-%m-%dT%H:%M:%SZ') + when Model then render_model(value, **options) + else raise ArgumentError, 'cannot render %p (%p)' % [value.class, value] + end + end + private def generate_expander(route, key_mapping) diff --git a/lib/travis/api/v3/renderer/accepted.rb b/lib/travis/api/v3/renderer/accepted.rb index c0e7412b..ad38d60f 100644 --- a/lib/travis/api/v3/renderer/accepted.rb +++ b/lib/travis/api/v3/renderer/accepted.rb @@ -1,5 +1,5 @@ module Travis::API::V3 - module Renderer::Error + module Renderer::Accepted extend self def render(type, **) diff --git a/lib/travis/api/v3/renderer/error.rb b/lib/travis/api/v3/renderer/error.rb index f78283ad..1d85d15e 100644 --- a/lib/travis/api/v3/renderer/error.rb +++ b/lib/travis/api/v3/renderer/error.rb @@ -7,7 +7,7 @@ module Travis::API::V3 :@type => 'error'.freeze, :error_type => error.type, :error_message => error.message, - **error.payload + **Renderer.render_value(error.payload) } end end diff --git a/lib/travis/api/v3/renderer/model_renderer.rb b/lib/travis/api/v3/renderer/model_renderer.rb index 0be52876..2cbc064d 100644 --- a/lib/travis/api/v3/renderer/model_renderer.rb +++ b/lib/travis/api/v3/renderer/model_renderer.rb @@ -1,8 +1,5 @@ module Travis::API::V3 class Renderer::ModelRenderer - PRIMITIVE = [String, Symbol, Numeric, true, false, nil] - private_constant :PRIMITIVE - def self.type(type = nil) @type = type if type @type = name[/[^:]+$/].underscore.to_sym unless defined? @type # allows setting type to nil @@ -43,23 +40,8 @@ module Travis::API::V3 result[:@href] = href if href fields = self.class.representations.fetch(representation) - fields.each { |field| result[field] = render_value(send(field)) } + fields.each { |field| result[field] = Renderer.render_value(send(field), script_name: script_name) } result end - - def render_model(model, type: model.class.name[/[^:]+$/].to_sym, mode: :minimal, **options) - Renderer[type].render(model, mode, script_name: script_name, **options) - end - - def render_value(value) - case value - when Hash then value.map { |k, v| [k, render_value(v)] }.to_h - when Array then value.map { |v | render_value(v) } - when *PRIMITIVE then value - when Time then value.strftime('%Y-%m-%dT%H:%M:%SZ') - when Model then render_model(value) - else raise ArgumentError, 'cannot render %p (%p)' % [value.class, value] - end - end end end diff --git a/lib/travis/api/v3/service.rb b/lib/travis/api/v3/service.rb index ea54a2c5..95aad833 100644 --- a/lib/travis/api/v3/service.rb +++ b/lib/travis/api/v3/service.rb @@ -16,7 +16,7 @@ module Travis::API::V3 end def query(type = self.class.result_type) - @queries[type] ||= Queries[type].new(params) + @queries[type] ||= Queries[type].new(params, self.class.result_type) end def find(type = self.class.result_type, *args) @@ -41,6 +41,12 @@ module Travis::API::V3 result end + def params_for?(prefix) + return true if params['@type'.freeze] == prefix + return true if params[prefix].is_a? Hash + params.keys.any? { |key| key.start_with? "#{prefix}." } + end + def accepted(type = self.class.result_type) Result.new(:accepted, type, status: 202) end diff --git a/lib/travis/api/v3/services/requests/create.rb b/lib/travis/api/v3/services/requests/create.rb index 1f734aaf..fb81e847 100644 --- a/lib/travis/api/v3/services/requests/create.rb +++ b/lib/travis/api/v3/services/requests/create.rb @@ -1,9 +1,17 @@ module Travis::API::V3 class Services::Requests::Create < Service + result_type :request + def run - not_implemented - query.schedule_for(find(:repository)) - accepted + raise LoginRequired unless access_control.logged_in? or access_control.full_access? + raise NotFound unless repository = find(:repository) + raise PushAccessRequired, repository: repository unless access_control.writable?(repository) + + user = find(:user) if access_control.full_access? and params_for? 'user'.freeze + user ||= access_control.user + + query.schedule(repository, user) + accepted(:request) end end end diff --git a/spec/v3/services/repository/find_spec.rb b/spec/v3/services/repository/find_spec.rb index 9cce296f..b11d334e 100644 --- a/spec/v3/services/repository/find_spec.rb +++ b/spec/v3/services/repository/find_spec.rb @@ -88,7 +88,7 @@ describe Travis::API::V3::Services::Repository::Find do before { Permission.create(repository: repo, user: repo.owner, pull: true) } before { repo.update_attribute(:private, true) } before { get("/v3/repo/#{repo.id}", {}, headers) } - before { repo.update_attribute(:private, false) } + after { repo.update_attribute(:private, false) } example { expect(last_response).to be_ok } example { expect(JSON.load(body)).to be == { "@type" => "repository", diff --git a/spec/v3/services/requests/create_spec.rb b/spec/v3/services/requests/create_spec.rb new file mode 100644 index 00000000..fd524be0 --- /dev/null +++ b/spec/v3/services/requests/create_spec.rb @@ -0,0 +1,255 @@ +require 'spec_helper' + +describe Travis::API::V3::Services::Requests::Create do + let(:repo) { Travis::API::V3::Models::Repository.where(owner_name: 'svenfuchs', name: 'minimal').first } + let(:sidekiq_payload) { Sidekiq::Client.last['args'].last[:payload] } + + before do + @original_sidekiq = Sidekiq::Client + Sidekiq.send(:remove_const, :Client) # to avoid a warning + Sidekiq::Client = [] + end + + after do + Sidekiq.send(:remove_const, :Client) # to avoid a warning + Sidekiq::Client = @original_sidekiq + end + + describe "not authenticated" do + before { post("/v3/repo/#{repo.id}/requests") } + example { expect(last_response.status).to be == 403 } + example { expect(JSON.load(body)).to be == { + "@type" => "error", + "error_type" => "login_required", + "error_message" => "login required" + }} + end + + describe "missing repository, authenticated" do + let(:token) { Travis::Api::App::AccessToken.create(user: repo.owner, app_id: 1) } + let(:headers) {{ 'HTTP_AUTHORIZATION' => "token #{token}" }} + before { post("/v3/repo/9999999999/requests", {}, headers) } + + example { expect(last_response.status).to be == 404 } + example { expect(JSON.load(body)).to be == { + "@type" => "error", + "error_type" => "not_found", + "error_message" => "repository not found (or insufficient access)", + "resource_type" => "repository" + }} + end + + describe "existing repository, no push access" do + let(:token) { Travis::Api::App::AccessToken.create(user: repo.owner, app_id: 1) } + let(:headers) {{ 'HTTP_AUTHORIZATION' => "token #{token}" }} + before { post("/v3/repo/#{repo.id}/requests", {}, headers) } + + example { expect(last_response.status).to be == 403 } + example { expect(JSON.load(body)).to be == { + "@type" => "error", + "error_type" => "push_access_required", + "error_message" => "push access required", + "repository" => { + "@type" => "repository", + "@href" => "/repo/#{repo.id}", + "id" => repo.id, + "slug" => "svenfuchs/minimal"} + }} + end + + describe "private repository, no access" do + let(:token) { Travis::Api::App::AccessToken.create(user: repo.owner, app_id: 1) } + let(:headers) {{ 'HTTP_AUTHORIZATION' => "token #{token}" }} + before { repo.update_attribute(:private, true) } + before { post("/v3/repo/#{repo.id}/requests", {}, headers) } + after { repo.update_attribute(:private, false) } + + example { expect(last_response.status).to be == 404 } + example { expect(JSON.load(body)).to be == { + "@type" => "error", + "error_type" => "not_found", + "error_message" => "repository not found (or insufficient access)", + "resource_type" => "repository" + }} + end + + describe "existing repository, push access" do + let(:params) {{}} + let(:token) { Travis::Api::App::AccessToken.create(user: repo.owner, app_id: 1) } + let(:headers) {{ 'HTTP_AUTHORIZATION' => "token #{token}" }} + before { Travis::API::V3::Models::Permission.create(repository: repo, user: repo.owner, push: true) } + before { post("/v3/repo/#{repo.id}/requests", params, headers) } + + example { expect(last_response.status).to be == 202 } + example { expect(JSON.load(body)).to be == { + "@type" => "pending", + "resource_type" => "request" + }} + + example { expect(sidekiq_payload).to be == { + repository: { id: repo.id }, + user: { id: repo.owner.id }, + message: nil, + branch: 'master', + config: {} + }} + + example { expect(Sidekiq::Client.last['queue']).to be == 'build_requests' } + example { expect(Sidekiq::Client.last['class']).to be == 'Travis::Sidekiq::BuildRequest' } + + describe "setting id has no effect" do + let(:params) {{ id: 42 }} + example { expect(sidekiq_payload).to be == { + repository: { id: repo.id }, + user: { id: repo.owner.id }, + message: nil, + branch: 'master', + config: {} + }} + end + + describe "setting repository has no effect" do + let(:params) {{ repository: { id: 42 } }} + example { expect(sidekiq_payload).to be == { + repository: { id: repo.id }, + user: { id: repo.owner.id }, + message: nil, + branch: 'master', + config: {} + }} + end + + describe "setting user has no effect" do + let(:params) {{ user: { id: 42 } }} + example { expect(sidekiq_payload).to be == { + repository: { id: repo.id }, + user: { id: repo.owner.id }, + message: nil, + branch: 'master', + config: {} + }} + end + + describe "overriding config" do + let(:params) {{ config: { script: 'true' } }} + example { expect(sidekiq_payload).to be == { + repository: { id: repo.id }, + user: { id: repo.owner.id }, + message: nil, + branch: 'master', + config: { 'script' => 'true' } + }} + end + + describe "overriding message" do + let(:params) {{ message: 'example' }} + example { expect(sidekiq_payload).to be == { + repository: { id: repo.id }, + user: { id: repo.owner.id }, + message: 'example', + branch: 'master', + config: {} + }} + end + + describe "overriding branch" do + let(:params) {{ branch: 'example' }} + example { expect(sidekiq_payload).to be == { + repository: { id: repo.id }, + user: { id: repo.owner.id }, + message: nil, + branch: 'example', + config: {} + }} + end + + describe "overriding branch (in request)" do + let(:params) {{ request: { branch: 'example' } }} + example { expect(sidekiq_payload).to be == { + repository: { id: repo.id }, + user: { id: repo.owner.id }, + message: nil, + branch: 'example', + config: {} + }} + end + + describe "overriding branch (with request prefix)" do + let(:params) {{ "request.branch" => 'example' }} + example { expect(sidekiq_payload).to be == { + repository: { id: repo.id }, + user: { id: repo.owner.id }, + message: nil, + branch: 'example', + config: {} + }} + end + + describe "overriding branch (with request type)" do + let(:params) {{ "@type" => "request", "branch" => 'example' }} + example { expect(sidekiq_payload).to be == { + repository: { id: repo.id }, + user: { id: repo.owner.id }, + message: nil, + branch: 'example', + config: {} + }} + end + + describe "overriding branch (with wrong type)" do + let(:params) {{ "@type" => "repository", "branch" => 'example' }} + example { expect(sidekiq_payload).to be == { + repository: { id: repo.id }, + user: { id: repo.owner.id }, + message: nil, + branch: 'master', + config: {} + }} + end + end + + + describe "existing repository, application with full access" do + let(:app_name) { 'travis-example' } + let(:app_secret) { '12345678' } + let(:sign_opts) { "a=#{app_name}" } + let(:signature) { OpenSSL::HMAC.hexdigest('sha256', app_secret, sign_opts) } + let(:headers) {{ 'HTTP_AUTHORIZATION' => "signature #{sign_opts}:#{signature}" }} + before { Travis.config.applications = { app_name => { full_access: true, secret: app_secret }}} + before { post("/v3/repo/#{repo.id}/requests", params, headers) } + + describe 'without setting user' do + let(:params) {{}} + example { expect(last_response.status).to be == 400 } + example { expect(JSON.load(body)).to be == { + "@type" => "error", + "error_type" => "wrong_params", + "error_message" => "missing user" + }} + end + + describe 'setting user' do + let(:params) {{ user: { id: repo.owner.id } }} + example { expect(last_response.status).to be == 202 } + example { expect(sidekiq_payload).to be == { + repository: { id: repo.id }, + user: { id: repo.owner.id }, + message: nil, + branch: 'master', + config: {} + }} + end + + describe 'setting branch' do + let(:params) {{ user: { id: repo.owner.id }, branch: 'example' }} + example { expect(last_response.status).to be == 202 } + example { expect(sidekiq_payload).to be == { + repository: { id: repo.id }, + user: { id: repo.owner.id }, + message: nil, + branch: 'example', + config: {} + }} + end + end +end \ No newline at end of file