# frozen_string_literal: true require 'json' require 'spec_helper' describe Minty::Mixins::HTTPProxy do before :each do dummy_instance = DummyClassForProxy.new dummy_instance.extend(Minty::Mixins::HTTPProxy) dummy_instance.base_uri = 'https://minty.page' dummy_instance.retry_count = 0 @instance = dummy_instance @exception = DummyClassForRestClient.new end %i[get delete].each do |http_method| context ".#{http_method}" do it { expect(@instance).to respond_to(http_method.to_sym) } it "should call send http #{http_method} method to path defined through HTTP" do expect(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: { params: {} }, payload: nil) .and_return(StubResponse.new({}, true, 200)) expect { @instance.send(http_method, '/test') }.not_to raise_error end it 'should not raise exception if data returned not in json format (should be fixed in v2)' do allow(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: { params: {} }, payload: nil) .and_return(StubResponse.new('Some random text here', true, 200)) expect { @instance.send(http_method, '/test') }.not_to raise_error expect(@instance.send(http_method, '/test')).to eql('Some random text here') end it "should raise Minty::Unauthorized on send http #{http_method} method to path defined through HTTP when 401 status received" do expect(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: { params: {} }, payload: nil) .and_return(StubResponse.new({}, false, 401)) expect { @instance.send(http_method, '/test') }.to raise_error(Minty::Unauthorized) end it "should raise Minty::NotFound on send http #{http_method} method to path defined through HTTP when 404 status received" do expect(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: { params: {} }, payload: nil) .and_return(StubResponse.new({}, false, 404)) expect { @instance.send(http_method, '/test') }.to raise_error(Minty::NotFound) end it "should raise Minty::Unsupported on send http #{http_method} method to path defined through HTTP when 418 or other unknown status received" do expect(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: { params: {} }, payload: nil) .and_return(StubResponse.new({}, false, 418)) expect { @instance.send(http_method, '/test') }.to raise_error(Minty::Unsupported) end it "should raise Minty::RequestTimeout on send http #{http_method} method to path defined through HTTP when RestClient::RequestTimeout received" do allow(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: { params: {} }, payload: nil) .and_raise(RestClient::Exceptions::OpenTimeout.new) expect { @instance.send(http_method, '/test') }.to raise_error(Minty::RequestTimeout) end it "should raise Minty::BadRequest on send http #{http_method} method to path defined through HTTP when 400 status received" do @exception.response = StubResponse.new({}, false, 400) allow(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: { params: {} }, payload: nil) .and_raise(@exception) expect { @instance.send(http_method, '/test') }.to raise_error(Minty::BadRequest) end it "should raise Minty::AccessDenied on send http #{http_method} method to path defined through HTTP when 403" do @exception.response = StubResponse.new({}, false, 403) allow(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: { params: {} }, payload: nil) .and_raise(@exception) expect { @instance.send(http_method, '/test') }.to raise_error(Minty::AccessDenied) end it "should raise Minty::RateLimitEncountered on send http #{http_method} method to path defined through HTTP when 429 recieved" do headers = { x_ratelimit_limit: 10, x_ratelimit_remaining: 0, x_ratelimit_reset: 1_560_564_149 } @exception.response = StubResponse.new({}, false, 429, headers) allow(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: { params: {} }, payload: nil) .and_raise(@exception) expect { @instance.send(http_method, '/test') }.to raise_error { |error| expect(error).to be_a(Minty::RateLimitEncountered) expect(error).to have_attributes( error_data: { headers: headers, code: 429 }, headers: headers, http_code: 429, reset: Time.zone.at(1_560_564_149) ) } end it "should raise Minty::ServerError on send http #{http_method} method to path defined through HTTP when 500 received" do @exception.response = StubResponse.new({}, false, 500) allow(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: { params: {} }, payload: nil) .and_raise(@exception) expect { @instance.send(http_method, '/test') }.to raise_error(Minty::ServerError) end it 'should normalize path with Addressable::URI' do expect(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/te%20st%23test', timeout: nil, headers: { params: {} }, payload: nil) .and_return(StubResponse.new({}, true, 200)) expect { @instance.send(http_method, '/te st#test') }.not_to raise_error end context "when status 429 is recieved on send http #{http_method} method" do it 'should retry 3 times when retry_count is not set' do retry_instance = DummyClassForProxy.new retry_instance.extend(Minty::Mixins::HTTPProxy) retry_instance.base_uri = 'https://minty.page' @exception.response = StubResponse.new({}, false, 429) allow(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: { params: {} }, payload: nil) .and_raise(@exception) expect(RestClient::Request).to receive(:execute).exactly(4).times expect { retry_instance.send(http_method, '/test') }.to raise_error { |error| expect(error).to be_a(Minty::RateLimitEncountered) } end it 'should retry 2 times when retry_count is set to 2' do retry_instance = DummyClassForProxy.new retry_instance.extend(Minty::Mixins::HTTPProxy) retry_instance.base_uri = 'https://minty.page' retry_instance.retry_count = 2 @exception.response = StubResponse.new({}, false, 429) allow(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: { params: {} }, payload: nil) .and_raise(@exception) expect(RestClient::Request).to receive(:execute).exactly(3).times expect { retry_instance.send(http_method, '/test') }.to raise_error { |error| expect(error).to be_a(Minty::RateLimitEncountered) } end it 'should not retry when retry_count is set to 0' do retry_instance = DummyClassForProxy.new retry_instance.extend(Minty::Mixins::HTTPProxy) retry_instance.base_uri = 'https://minty.page' retry_instance.retry_count = 0 @exception.response = StubResponse.new({}, false, 429) allow(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: { params: {} }, payload: nil) .and_raise(@exception) expect(RestClient::Request).to receive(:execute).exactly(1).times expect { retry_instance.send(http_method, '/test') }.to raise_error { |error| expect(error).to be_a(Minty::RateLimitEncountered) } end it 'should have have random retry times grow with jitter backoff' do retry_instance = DummyClassForProxy.new retry_instance.extend(Minty::Mixins::HTTPProxy) retry_instance.base_uri = 'https://minty.page' retry_instance.retry_count = 2 time_entries = [] @time_start @exception.response = StubResponse.new({}, false, 429) allow(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: { params: {} }, payload: nil) do time_entries.push(Time.now.to_f - @time_start.to_f) @time_start = Time.now.to_f # restart the clock raise @exception end @time_start = Time.now.to_f # start the clock begin retry_instance.send(http_method, '/test') rescue StandardError nil end time_entries_first_set = time_entries.shift(time_entries.length) begin retry_instance.send(http_method, '/test') rescue StandardError nil end time_entries.each_with_index do |entry, index| expect(entry != time_entries_first_set[index]) if index > 0 # skip the first request end end end end end %i[post put patch].each do |http_method| context ".#{http_method}" do it { expect(@instance).to respond_to(http_method.to_sym) } it "should call send http #{http_method} method to path defined through HTTP" do expect(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: nil, payload: '{}') .and_return(StubResponse.new({}, true, 200)) expect { @instance.send(http_method, '/test') }.not_to raise_error end it 'should not raise exception if data returned not in json format (should be fixed in v2)' do allow(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: nil, payload: '{}') .and_return(StubResponse.new('Some random text here', true, 200)) expect { @instance.send(http_method, '/test') }.not_to raise_error expect(@instance.send(http_method, '/test')).to eql('Some random text here') end it "should raise Minty::Unauthorized on send http #{http_method} method to path defined through HTTP when 401 status received" do @exception.response = StubResponse.new({}, false, 401) allow(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: nil, payload: '{}') .and_raise(@exception) expect { @instance.send(http_method, '/test') }.to raise_error(Minty::Unauthorized) end it "should raise Minty::RateLimitEncountered on send http #{http_method} method to path defined through HTTP when 429 status received" do headers = { x_ratelimit_limit: 10, x_ratelimit_remaining: 0, x_ratelimit_reset: 1_560_564_149 } @exception.response = StubResponse.new({}, false, 429, headers) allow(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: nil, payload: '{}') .and_raise(@exception) expect { @instance.send(http_method, '/test') }.to raise_error { |error| expect(error).to be_a(Minty::RateLimitEncountered) expect(error).to have_attributes( error_data: { headers: headers, code: 429 }, headers: headers, http_code: 429, reset: Time.zone.at(1_560_564_149) ) } end it "should raise Minty::NotFound on send http #{http_method} method to path defined through HTTP when 404 status received" do @exception.response = StubResponse.new({}, false, 404) allow(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: nil, payload: '{}') .and_raise(@exception) expect { @instance.send(http_method, '/test') }.to raise_error(Minty::NotFound) end it "should raise Minty::Unsupported on send http #{http_method} method to path defined through HTTP when 418 or other unknown status received" do @exception.response = StubResponse.new({}, false, 418) allow(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: nil, payload: '{}') .and_raise(@exception) expect { @instance.send(http_method, '/test') }.to raise_error(Minty::Unsupported) end it "should raise Minty::RequestTimeout on send http #{http_method} method to path defined through HTTP when RestClient::RequestTimeout received" do allow(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: nil, payload: '{}') .and_raise(RestClient::Exceptions::OpenTimeout.new) expect { @instance.send(http_method, '/test') }.to raise_error(Minty::RequestTimeout) end it "should raise Minty::BadRequest on send http #{http_method} method to path defined through HTTP when 400 status received" do @exception.response = StubResponse.new({}, false, 400) allow(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: nil, payload: '{}') .and_raise(@exception) expect { @instance.send(http_method, '/test') }.to raise_error(Minty::BadRequest) end it "should raise Minty::ServerError on send http #{http_method} method to path defined through HTTP when 500 received" do @exception.response = StubResponse.new({}, false, 500) allow(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: nil, payload: '{}') .and_raise(@exception) expect { @instance.send(http_method, '/test') }.to raise_error(Minty::ServerError) end it 'should normalize path with Addressable::URI' do expect(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/te%20st', timeout: nil, headers: nil, payload: '{}') .and_return(StubResponse.new({}, true, 200)) expect { @instance.send(http_method, '/te st') }.not_to raise_error end it 'should give the JSON representation of the error as the error message' do res = JSON.generate('statusCode' => 404, 'error' => 'Bad Request', 'message' => "Path validation error: 'String does not match pattern ^.+\\|.+$: 3241312' on property id (The user_id of the user to retrieve).", 'errorCode' => 'invalid_uri') expect(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: nil, payload: '{}') .and_return(StubResponse.new(res, true, 404)) expect { @instance.send(http_method, '/test') }.to raise_error(Minty::NotFound, res) end context "when status 429 is recieved on send http #{http_method} method" do it 'should retry 3 times when retry_count is not set' do retry_instance = DummyClassForProxy.new retry_instance.extend(Minty::Mixins::HTTPProxy) retry_instance.base_uri = 'https://minty.page' @exception.response = StubResponse.new({}, false, 429) allow(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: nil, payload: '{}') .and_raise(@exception) expect(RestClient::Request).to receive(:execute).exactly(4).times expect { retry_instance.send(http_method, '/test') }.to raise_error { |error| expect(error).to be_a(Minty::RateLimitEncountered) } end it 'should retry 2 times when retry_count is set to 2' do retry_instance = DummyClassForProxy.new retry_instance.extend(Minty::Mixins::HTTPProxy) retry_instance.base_uri = 'https://minty.page' retry_instance.retry_count = 2 @exception.response = StubResponse.new({}, false, 429) allow(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: nil, payload: '{}') .and_raise(@exception) expect(RestClient::Request).to receive(:execute).exactly(3).times expect { retry_instance.send(http_method, '/test') }.to raise_error { |error| expect(error).to be_a(Minty::RateLimitEncountered) } end it 'should not retry when retry_count is set to 0' do retry_instance = DummyClassForProxy.new retry_instance.extend(Minty::Mixins::HTTPProxy) retry_instance.base_uri = 'https://minty.page' retry_instance.retry_count = 0 @exception.response = StubResponse.new({}, false, 429) allow(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: nil, payload: '{}') .and_raise(@exception) expect(RestClient::Request).to receive(:execute).exactly(1).times expect { retry_instance.send(http_method, '/test') }.to raise_error { |error| expect(error).to be_a(Minty::RateLimitEncountered) } end it 'should have have random retry times grow with jitter backoff' do retry_instance = DummyClassForProxy.new retry_instance.extend(Minty::Mixins::HTTPProxy) retry_instance.base_uri = 'https://minty.page' retry_instance.retry_count = 2 time_entries = [] @time_start @exception.response = StubResponse.new({}, false, 429) allow(RestClient::Request).to receive(:execute).with(method: http_method, url: 'https://minty.page/test', timeout: nil, headers: nil, payload: '{}') do time_entries.push(Time.now.to_f - @time_start.to_f) @time_start = Time.now.to_f # restart the clock raise @exception end @time_start = Time.now.to_f # start the clock begin retry_instance.send(http_method, '/test') rescue StandardError nil end time_entries_first_set = time_entries.shift(time_entries.length) begin retry_instance.send(http_method, '/test') rescue StandardError nil end time_entries.each_with_index do |entry, index| expect(entry != time_entries_first_set[index]) if index > 0 # skip the first request end end end end end context 'Renewing tokens' do let(:httpproxy_instance) do DummyClassForTokens.new( client_id: 'test-client-id', client_secret: 'test-client-secret', domain: 'minty.page' ) end %i[get delete].each do |http_method| context "for #{http_method}" do it 'should renew the token' do expect(RestClient::Request).to receive(:execute).with(hash_including( method: :post, url: 'https://minty.page/oauth/token' )).and_return(StubResponse.new({ 'access_token' => 'access_token', 'expires_in' => 86_400 }, true, 200)) expect(RestClient::Request).to receive(:execute).with(hash_including( method: http_method, url: 'https://minty.page/test' )).and_return(StubResponse.new('Some random text here', true, 200)) expect { httpproxy_instance.send(http_method, '/test') }.not_to raise_error end end end %i[post put patch].each do |http_method| context "for #{http_method}" do it 'should renew the token' do expect(RestClient::Request).to receive(:execute).with(hash_including( method: :post, url: 'https://minty.page/oauth/token' )).and_return(StubResponse.new({ 'access_token' => 'access_token', 'expires_in' => 86_400 }, true, 200)) expect(RestClient::Request).to receive(:execute).with(hash_including( method: http_method, url: 'https://minty.page/test', headers: hash_including('Authorization' => 'Bearer access_token') )).and_return(StubResponse.new('Some random text here', true, 200)) expect { httpproxy_instance.send(http_method, '/test') }.not_to raise_error end end end end context 'Using cached tokens' do let(:httpproxy_instance) do DummyClassForTokens.new( client_id: 'test-client-id', client_secret: 'test-client-secret', domain: 'minty.page', token: 'access_token', token_expires_at: Time.now.to_i + 86_400 ) end %i[get delete].each do |http_method| context "for #{http_method}" do it 'should use the cached token' do expect(RestClient::Request).not_to receive(:execute).with(hash_including( method: :post, url: 'https://minty.page/oauth/token' )) expect(RestClient::Request).to receive(:execute).with(hash_including( method: http_method, url: 'https://minty.page/test', headers: hash_including(params: {}, 'Authorization' => 'Bearer access_token') )).and_return(StubResponse.new('Some random text here', true, 200)) expect { httpproxy_instance.send(http_method, '/test') }.not_to raise_error end end end %i[post put patch].each do |http_method| context "for #{http_method}" do it 'should use the cached token' do expect(RestClient::Request).not_to receive(:execute).with(hash_including( method: :post, url: 'https://minty.page/oauth/token' )) expect(RestClient::Request).to receive(:execute).with(hash_including( method: http_method, url: 'https://minty.page/test', headers: hash_including('Authorization' => 'Bearer access_token') )).and_return(StubResponse.new('Some random text here', true, 200)) expect { httpproxy_instance.send(http_method, '/test') }.not_to raise_error end end end end context 'Normal operation' do let(:httpproxy_instance) do DummyClassForTokens.new( client_id: 'test-client-id', client_secret: 'test-client-secret', domain: 'minty.page', token: 'access_token', token_expires_at: Time.now.to_i + 86_400 ) end # This sets up a test matrix to verify that both :get and :delete calls (the only two HTTP methods in the proxy that mutated headers) # don't bleed query params into subsequent calls to :post :patch and :put. %i[get delete].each do |http_get_delete| %i[post patch put].each do |http_ppp| it "should not bleed :#{http_get_delete} headers/parameters to the subsequent :#{http_ppp} request" do expect(RestClient::Request).to receive(:execute).with(hash_including( method: http_get_delete, url: "https://minty.page/test-#{http_get_delete}", headers: hash_including(params: { email: 'test@test.com' }) )).and_return(StubResponse.new('OK', true, 200)) # email: parameter that is sent in the GET request should not appear # as a parameter in the `headers` hash for the subsequent PATCH request. expect(RestClient::Request).to receive(:execute).with(hash_including( method: http_ppp, url: "https://minty.page/test-#{http_ppp}", headers: hash_not_including(:params) )).and_return(StubResponse.new('OK', true, 200)) expect do httpproxy_instance.send(http_get_delete, "/test-#{http_get_delete}", { email: 'test@test.com' }) end.not_to raise_error expect { httpproxy_instance.send(http_ppp, "/test-#{http_ppp}") }.not_to raise_error end end end end end