diff --git a/lib/travis/api/app/endpoint/authorization.rb b/lib/travis/api/app/endpoint/authorization.rb index a7466a569..96876bcf1 100644 --- a/lib/travis/api/app/endpoint/authorization.rb +++ b/lib/travis/api/app/endpoint/authorization.rb @@ -319,6 +319,7 @@ def create_state def state_ok?(state, provider = :github) cookie_state = request.cookies[cookie_name(provider)] + state = CGI.unescape(state) state == cookie_state and redis.srem('github:states', state.to_s.split(":::", 1)) end diff --git a/lib/travis/api/v3/billing_client.rb b/lib/travis/api/v3/billing_client.rb index 5bef4cb61..88d2c8a3e 100644 --- a/lib/travis/api/v3/billing_client.rb +++ b/lib/travis/api/v3/billing_client.rb @@ -235,6 +235,16 @@ def create_auto_refill(plan_id, is_enabled) handle_errors_and_respond(response) end + def share(plan_id, receiver) + response = connection.post("/v2/subscriptions/#{plan_id}/share", {plan: plan_id, receiver: receiver, requested_by: @user_id }) + handle_errors_and_respond(response) + end + + def delete_share(plan_id, receiver) + response = connection.delete("/v2/subscriptions/#{plan_id}/share", {plan: plan_id, receiver: receiver, requested_by: @user_id }) + handle_errors_and_respond(response) + end + def update_auto_refill(addon_id, threshold, amount) response = connection.patch('/auto_refill', {id: addon_id, threshold: threshold, amount: amount}) handle_errors_and_respond(response) diff --git a/lib/travis/api/v3/models/plan_share.rb b/lib/travis/api/v3/models/plan_share.rb new file mode 100644 index 000000000..ec1c5da32 --- /dev/null +++ b/lib/travis/api/v3/models/plan_share.rb @@ -0,0 +1,14 @@ +module Travis::API::V3 + class Models::PlanShare + attr_reader :plan_id, :donor, :receiver, :shared_by, :created_at, :admin_revoked, :credits_consumed + def initialize(attributes = {}) + @plan_id = attributes.fetch('plan_id') + @donor = attributes.fetch('donor') + @receiver = attributes.fetch('receiver') + @shared_by = attributes.fetch('shared_by') + @created_at = attributes.fetch('created_at') + @admin_revoked = attributes.fetch('admin_revoked') + @credits_consumed = attributes.fetch('credits_consumed') + end + end +end diff --git a/lib/travis/api/v3/models/v2_subscription.rb b/lib/travis/api/v3/models/v2_subscription.rb index 6a18551ac..cb9ed273b 100644 --- a/lib/travis/api/v3/models/v2_subscription.rb +++ b/lib/travis/api/v3/models/v2_subscription.rb @@ -4,7 +4,7 @@ class Models::V2Subscription attr_reader :id, :plan, :permissions, :source, :billing_info, :credit_card_info, :owner, :status, :valid_to, :canceled_at, :client_secret, :payment_intent, :addons, :auto_refill, :available_standalone_addons, :created_at, :scheduled_plan_name, - :cancellation_requested, :current_trial, :defer_pause + :cancellation_requested, :current_trial, :defer_pause, :plan_shares def initialize(attributes = {}) @id = attributes.fetch('id') @@ -38,6 +38,7 @@ def initialize(attributes = {}) @current_trial = Models::V2Trial.new(current_trial) end @defer_pause = attributes.fetch('defer_pause', false) + @plan_shares = attributes['plan_shares'] && attributes['plan_shares'].map { |sp| Models::PlanShare.new(sp) } end end diff --git a/lib/travis/api/v3/queries/v2_subscription.rb b/lib/travis/api/v3/queries/v2_subscription.rb index 352af8731..5c825c0ac 100644 --- a/lib/travis/api/v3/queries/v2_subscription.rb +++ b/lib/travis/api/v3/queries/v2_subscription.rb @@ -93,6 +93,16 @@ def update_auto_refill(user_id, addon_id) client.update_auto_refill(addon_id, threshold, amount) end + def share(user_id, receiver_id) + client = BillingClient.new(user_id) + client.share(params['subscription.id'], receiver_id) + end + + def delete_share(user_id, receiver_id) + client = BillingClient.new(user_id) + client.delete_share(params['subscription.id'], receiver_id) + end + private def recaptcha_client diff --git a/lib/travis/api/v3/renderer/plan_share.rb b/lib/travis/api/v3/renderer/plan_share.rb new file mode 100644 index 000000000..a9a7629c6 --- /dev/null +++ b/lib/travis/api/v3/renderer/plan_share.rb @@ -0,0 +1,6 @@ +module Travis::API::V3 + class Renderer::PlanShare < ModelRenderer + representation(:standard, :plan_id, :donor, :receiver, :shared_by, :created_at, :admin_revoked, :credits_consumed) + representation(:minimal, :plan_id, :donor, :receiver, :shared_by, :created_at, :admin_revoked, :credits_consumed) + end +end diff --git a/lib/travis/api/v3/renderer/plan_shares.rb b/lib/travis/api/v3/renderer/plan_shares.rb new file mode 100644 index 000000000..c34740254 --- /dev/null +++ b/lib/travis/api/v3/renderer/plan_shares.rb @@ -0,0 +1,6 @@ +module Travis::API::V3 + class Renderer::PlanShares < CollectionRenderer + type :plan_shares + collection_key :plan_shares + end +end diff --git a/lib/travis/api/v3/renderer/v2_subscription.rb b/lib/travis/api/v3/renderer/v2_subscription.rb index 4f9ed26ae..6bbef8f41 100644 --- a/lib/travis/api/v3/renderer/v2_subscription.rb +++ b/lib/travis/api/v3/renderer/v2_subscription.rb @@ -1,6 +1,6 @@ module Travis::API::V3 class Renderer::V2Subscription < ModelRenderer - representation(:standard, :id, :plan, :addons, :auto_refill, :status, :valid_to, :canceled_at, :source, :owner, :client_secret, :billing_info, :credit_card_info, :payment_intent, :created_at, :scheduled_plan_name, :cancellation_requested, :current_trial, :defer_pause) + representation(:standard, :id, :plan, :addons, :auto_refill, :status, :valid_to, :canceled_at, :source, :owner, :client_secret, :billing_info, :credit_card_info, :payment_intent, :created_at, :scheduled_plan_name, :cancellation_requested, :current_trial, :defer_pause, :plan_shares) def billing_info Renderer.render_model(model.billing_info, mode: :standard) unless model.billing_info.nil? @@ -21,6 +21,7 @@ def payment_intent def current_trial Renderer.render_model(model.current_trial,mode: :standard) unless model.current_trial.nil? end + end class Renderer::V2BillingInfo < ModelRenderer diff --git a/lib/travis/api/v3/routes.rb b/lib/travis/api/v3/routes.rb index bfb4b9c57..d2540ae76 100644 --- a/lib/travis/api/v3/routes.rb +++ b/lib/travis/api/v3/routes.rb @@ -446,6 +446,8 @@ module Routes get :auto_refill, '/auto_refill' patch :toggle_auto_refill, '/auto_refill' patch :update_auto_refill, '/update_auto_refill' + post :share, '/share' + delete :share, '/share' end hidden_resource :trials do diff --git a/lib/travis/api/v3/services/v2_subscription/share.rb b/lib/travis/api/v3/services/v2_subscription/share.rb new file mode 100644 index 000000000..f24c55352 --- /dev/null +++ b/lib/travis/api/v3/services/v2_subscription/share.rb @@ -0,0 +1,14 @@ +module Travis::API::V3 + class Services::V2Subscription::Share < Service + params :receiver_id, :receiver + def run! + raise LoginRequired unless access_control.full_access_or_logged_in? + if @env['REQUEST_METHOD'] == 'DELETE' then + query.delete_share(access_control.user.id, params['receiver_id']) + else + query.share(access_control.user.id, params['receiver_id']) + end + no_content + end + end +end diff --git a/spec/v3/services/v2_subscription/share_spec.rb b/spec/v3/services/v2_subscription/share_spec.rb new file mode 100644 index 000000000..f821dd691 --- /dev/null +++ b/spec/v3/services/v2_subscription/share_spec.rb @@ -0,0 +1,73 @@ +describe Travis::API::V3::Services::V2Subscription::Share, set_app: true, billing_spec_helper: true do + let(:billing_url) { 'http://billingfake.travis-ci.com' } + let(:billing_auth_key) { 'secret' } + let(:receiver) { FactoryBot.create(:org) } + let(:data) { {'plan'=> subscription_id, 'receiver_id' => receiver.id } } + + before do + Travis.config.billing.url = billing_url + Travis.config.billing.auth_key = billing_auth_key + end + + context 'unauthenticated' do + it 'responds 403 for post' do + post('/v3/v2_subscription/123/share', {}) + + expect(last_response.status).to eq(403) + end + + it 'responds 403 for delete' do + delete('/v3/v2_subscription/123/share', {}) + + expect(last_response.status).to eq(403) + end + end + + context 'authenticated create share' do + let(:user) { FactoryBot.create(:user) } + let(:token) { Travis::Api::App::AccessToken.create(user: user, app_id: 1) } + let(:headers) {{ 'HTTP_AUTHORIZATION' => "token #{token}", + 'CONTENT_TYPE' => 'application/json' }} + let(:subscription_id) { rand(999) } + + let!(:stubbed_request) do + stub_billing_request(:post, "/v2/subscriptions/#{subscription_id}/share", auth_key: billing_auth_key, user_id: user.id) + .with(body: { + 'plan' => subscription_id.to_s, + 'receiver' => receiver.id, + 'requested_by' => user.id + }) + .to_return(status: 204) + end + + it 'shares the subscription' do + post("/v3/v2_subscription/#{subscription_id}/share", JSON.generate(data), headers) + + expect(last_response.status).to eq(204) + expect(stubbed_request).to have_been_made.once + end + end + + context 'authenticated delete share' do + let(:user) { FactoryBot.create(:user) } + let(:token) { Travis::Api::App::AccessToken.create(user: user, app_id: 1) } + let(:headers) {{ 'HTTP_AUTHORIZATION' => "token #{token}", + 'CONTENT_TYPE' => 'application/json' }} + let(:subscription_id) { rand(999) } + + let!(:stubbed_request) do + stub_request(:delete, "#{billing_url}/v2/subscriptions/#{subscription_id}/share?plan=#{subscription_id}&receiver=#{receiver.id}&requested_by=#{user.id}").with( + headers: { + 'X-Travis-User-Id' => user.id + } + ) + .to_return(status: 204) + end + + it 'deletes subscription share' do + delete("/v3/v2_subscription/#{subscription_id}/share", JSON.generate(data), headers) + expect(last_response.status).to eq(204) + expect(stubbed_request).to have_been_made.once + end + end +end diff --git a/spec/v3/services/v2_subscriptions/all_spec.rb b/spec/v3/services/v2_subscriptions/all_spec.rb index 2641061fb..95083de1b 100644 --- a/spec/v3/services/v2_subscriptions/all_spec.rb +++ b/spec/v3/services/v2_subscriptions/all_spec.rb @@ -87,6 +87,7 @@ 'cancellation_requested' => false, 'current_trial' => nil, 'defer_pause' => false, + 'plan_shares' => nil, 'plan' => { '@type' => 'v2_plan_config', '@representation' => 'standard', diff --git a/spec/v3/services/v2_subscriptions/create_spec.rb b/spec/v3/services/v2_subscriptions/create_spec.rb index 33b5f90ab..efbf62f9e 100644 --- a/spec/v3/services/v2_subscriptions/create_spec.rb +++ b/spec/v3/services/v2_subscriptions/create_spec.rb @@ -212,6 +212,7 @@ 'cancellation_requested' => false, 'current_trial' => nil, 'defer_pause' => false, + 'plan_shares' => nil, 'plan' => { '@type' => 'v2_plan_config', '@representation' => 'standard',