require 'spec_helper' require 'puppet/network/http/connection' require 'puppet/test_ca' describe Puppet::Network::HTTP::Connection do let(:host) { "me.example.com" } let(:port) { 8140 } let(:path) { '/foo' } let(:url) { "https://#{host}:#{port}#{path}" } let(:params) { { 'key' => 'a value' } } let(:encoded_url_with_params) { "#{url}?%7B%22key%22:%22a%20value%22%7D" } let(:ssl_context) { Puppet::SSL::SSLProvider.new.create_system_context(cacerts: []) } let(:verifier) { Puppet::SSL::Verifier.new(host, ssl_context) } shared_examples_for "an HTTP connection" do |klass| subject { klass.new(host, port, :verifier => verifier) } context "when providing HTTP connections" do context "when initializing http instances" do it "should return an http instance created with the passed host and port" do conn = klass.new(host, port, :verifier => verifier) expect(conn.address).to eq(host) expect(conn.port).to eq(port) end it "should enable ssl on the http instance by default" do conn = klass.new(host, port, :verifier => verifier) expect(conn).to be_use_ssl end it "can disable ssl using an option and ignore the verify" do conn = klass.new(host, port, :use_ssl => false) expect(conn).to_not be_use_ssl end it "can enable ssl using an option" do conn = klass.new(host, port, :use_ssl => true, :verifier => verifier) expect(conn).to be_use_ssl end it "ignores the ':verify' option when ssl is disabled" do conn = klass.new(host, port, :use_ssl => false, :verifier => verifier) expect(conn.verifier).to be_nil end it "wraps the validator in an adapter" do conn = klass.new(host, port, :verifier => verifier) expect(conn.verifier).to be_a(Puppet::SSL::Verifier) end it "should raise Puppet::Error when invalid options are specified" do expect { klass.new(host, port, :invalid_option => nil) }.to raise_error(Puppet::Error, 'Unrecognized option(s): :invalid_option') end it "accepts a verifier" do verifier = Puppet::SSL::Verifier.new(host, double('ssl_context')) conn = klass.new(host, port, :use_ssl => true, :verifier => verifier) expect(conn.verifier).to eq(verifier) end it "raises if the wrong verifier class is specified" do expect { klass.new(host, port, :verifier => Object.new) }.to raise_error(ArgumentError, "Expected an instance of Puppet::SSL::Verifier but was passed a Object") end end end context "for streaming GET requests" do it 'yields the response' do stub_request(:get, url) expect { |b| subject.request_get('/foo', {}, &b) }.to yield_with_args(Net::HTTPResponse) end it "stringifies keys and encodes values in the query" do stub_request(:get, encoded_url_with_params) subject.request_get("#{path}?#{params.to_json}") { |_| } end it "merges custom headers with default ones" do stub_request(:get, url).with(headers: { 'X-Foo' => 'Bar', 'User-Agent' => /./ }) subject.request_get(path, {'X-Foo' => 'Bar'}) { |_| } end it "returns the response" do stub_request(:get, url) response = subject.request_get(path) { |_| } expect(response).to be_an_instance_of(Net::HTTPOK) expect(response.code).to eq("200") end it "accepts a URL string as the path" do url_with_query = "#{url}?foo=bar" stub_request(:get, url_with_query) response = subject.request_get(url_with_query) { |_| } expect(response).to be_an_instance_of(Net::HTTPOK) end end context "for streaming head requests" do it 'yields the response when request_head is called' do stub_request(:head, url) expect { |b| subject.request_head('/foo', {}, &b) }.to yield_with_args(Net::HTTPResponse) end it "stringifies keys and encodes values in the query" do stub_request(:head, encoded_url_with_params) subject.request_head("#{path}?#{params.to_json}") { |_| } end it "merges custom headers with default ones" do stub_request(:head, url).with(headers: { 'X-Foo' => 'Bar', 'User-Agent' => /./ }) subject.request_head(path, {'X-Foo' => 'Bar'}) { |_| } end it "returns the response" do stub_request(:head, url) response = subject.request_head(path) { |_| } expect(response).to be_an_instance_of(Net::HTTPOK) expect(response.code).to eq("200") end it "accepts a URL string as the path" do url_with_query = "#{url}?foo=bar" stub_request(:head, url_with_query) response = subject.request_head(url_with_query) { |_| } expect(response).to be_an_instance_of(Net::HTTPOK) end end context "for streaming post requests" do it 'yields the response when request_post is called' do stub_request(:post, url) expect { |b| subject.request_post('/foo', "param: value", &b) }.to yield_with_args(Net::HTTPResponse) end it "stringifies keys and encodes values in the query" do stub_request(:post, encoded_url_with_params) subject.request_post("#{path}?#{params.to_json}", "") { |_| } end it "merges custom headers with default ones" do stub_request(:post, url).with(headers: { 'X-Foo' => 'Bar', 'User-Agent' => /./ }) subject.request_post(path, "", {'X-Foo' => 'Bar'}) { |_| } end it "returns the response" do stub_request(:post, url) response = subject.request_post(path, "") { |_| } expect(response).to be_an_instance_of(Net::HTTPOK) expect(response.code).to eq("200") end it "accepts a URL string as the path" do url_with_query = "#{url}?foo=bar" stub_request(:post, url_with_query) response = subject.request_post(url_with_query, "") { |_| } expect(response).to be_an_instance_of(Net::HTTPOK) end end context "for GET requests" do it "includes default HTTP headers" do stub_request(:get, url).with(headers: {'User-Agent' => /./}) subject.get(path) end it "stringifies keys and encodes values in the query" do stub_request(:get, encoded_url_with_params) subject.get("#{path}?#{params.to_json}") end it "merges custom headers with default ones" do stub_request(:get, url).with(headers: { 'X-Foo' => 'Bar', 'User-Agent' => /./ }) subject.get(path, {'X-Foo' => 'Bar'}) end it "returns the response" do stub_request(:get, url) response = subject.get(path) expect(response).to be_an_instance_of(Net::HTTPOK) expect(response.code).to eq("200") end it "returns the entire response body" do stub_request(:get, url).to_return(body: "abc") response = subject.get(path) expect(response.body).to eq("abc") end it "accepts a URL string as the path" do url_with_query = "#{url}?foo=bar" stub_request(:get, url_with_query) response = subject.get(url_with_query) expect(response).to be_an_instance_of(Net::HTTPOK) end end context "for HEAD requests" do it "includes default HTTP headers" do stub_request(:head, url).with(headers: {'User-Agent' => /./}) subject.head(path) end it "stringifies keys and encodes values in the query" do stub_request(:head, encoded_url_with_params) subject.head("#{path}?#{params.to_json}") end it "merges custom headers with default ones" do stub_request(:head, url).with(headers: { 'X-Foo' => 'Bar', 'User-Agent' => /./ }) subject.head(path, {'X-Foo' => 'Bar'}) end it "returns the response" do stub_request(:head, url) response = subject.head(path) expect(response).to be_an_instance_of(Net::HTTPOK) expect(response.code).to eq("200") end it "accepts a URL string as the path" do url_with_query = "#{url}?foo=bar" stub_request(:head, url_with_query) response = subject.head(url_with_query) expect(response).to be_an_instance_of(Net::HTTPOK) end end context "for PUT requests" do it "includes default HTTP headers" do stub_request(:put, url).with(headers: {'User-Agent' => /./}) subject.put(path, "", {'Content-Type' => 'text/plain'}) end it "stringifies keys and encodes values in the query" do stub_request(:put, encoded_url_with_params) subject.put("#{path}?#{params.to_json}", "") end it "includes custom headers" do stub_request(:put, url).with(headers: { 'X-Foo' => 'Bar' }) subject.put(path, "", {'X-Foo' => 'Bar', 'Content-Type' => 'text/plain'}) end it "returns the response" do stub_request(:put, url) response = subject.put(path, "", {'Content-Type' => 'text/plain'}) expect(response).to be_an_instance_of(Net::HTTPOK) expect(response.code).to eq("200") end it "sets content-type for the body" do stub_request(:put, url).with(headers: {"Content-Type" => "text/plain"}) subject.put(path, "hello", {'Content-Type' => 'text/plain'}) end it 'sends an empty body' do stub_request(:put, url).with(body: '') subject.put(path, nil) end it 'defaults content-type to application/x-www-form-urlencoded' do stub_request(:put, url).with(headers: {'Content-Type' => 'application/x-www-form-urlencoded'}) subject.put(path, '') end it "accepts a URL string as the path" do url_with_query = "#{url}?foo=bar" stub_request(:put, url_with_query) response = subject.put(url_with_query, '') expect(response).to be_an_instance_of(Net::HTTPOK) end end context "for POST requests" do it "includes default HTTP headers" do stub_request(:post, url).with(headers: {'User-Agent' => /./}) subject.post(path, "", {'Content-Type' => 'text/plain'}) end it "stringifies keys and encodes values in the query" do stub_request(:post, encoded_url_with_params) subject.post("#{path}?#{params.to_json}", "", {'Content-Type' => 'text/plain'}) end it "includes custom headers" do stub_request(:post, url).with(headers: { 'X-Foo' => 'Bar' }) subject.post(path, "", {'X-Foo' => 'Bar', 'Content-Type' => 'text/plain'}) end it "returns the response" do stub_request(:post, url) response = subject.post(path, "", {'Content-Type' => 'text/plain'}) expect(response).to be_an_instance_of(Net::HTTPOK) expect(response.code).to eq("200") end it "sets content-type for the body" do stub_request(:post, url).with(headers: {"Content-Type" => "text/plain"}) subject.post(path, "hello", {'Content-Type' => 'text/plain'}) end it 'sends an empty body' do stub_request(:post, url).with(body: '') subject.post(path, nil) end it 'defaults content-type to application/x-www-form-urlencoded' do stub_request(:post, url).with(headers: {'Content-Type' => 'application/x-www-form-urlencoded'}) subject.post(path, "") end it "accepts a URL string as the path" do url_with_query = "#{url}?foo=bar" stub_request(:post, url_with_query) response = subject.post(url_with_query, '') expect(response).to be_an_instance_of(Net::HTTPOK) end end context "for DELETE requests" do it "includes default HTTP headers" do stub_request(:delete, url).with(headers: {'User-Agent' => /./}) subject.delete(path) end it "merges custom headers with default ones" do stub_request(:delete, url).with(headers: { 'X-Foo' => 'Bar', 'User-Agent' => /./ }) subject.delete(path, {'X-Foo' => 'Bar'}) end it "stringifies keys and encodes values in the query" do stub_request(:delete, encoded_url_with_params) subject.delete("#{path}?#{params.to_json}") end it "returns the response" do stub_request(:delete, url) response = subject.delete(path) expect(response).to be_an_instance_of(Net::HTTPOK) expect(response.code).to eq("200") end it "returns the entire response body" do stub_request(:delete, url).to_return(body: "abc") expect(subject.delete(path).body).to eq("abc") end it "accepts a URL string as the path" do url_with_query = "#{url}?foo=bar" stub_request(:delete, url_with_query) response = subject.delete(url_with_query) expect(response).to be_an_instance_of(Net::HTTPOK) end end context "when response is a redirect" do subject { klass } def create_connection(options = {}) options[:use_ssl] = false options[:verifier] = verifier subject.new(host, port, options) end def redirect_to(url) { status: 302, headers: { 'Location' => url } } end it "should follow the redirect to the final resource location" do stub_request(:get, "http://me.example.com:8140/foo").to_return(redirect_to("http://me.example.com:8140/bar")) stub_request(:get, "http://me.example.com:8140/bar").to_return(status: 200) create_connection.get('/foo') end def expects_limit_exceeded(conn) expect { conn.get('/') }.to raise_error(Puppet::Network::HTTP::RedirectionLimitExceededException) end it "should not follow any redirects when the limit is 0" do stub_request(:get, "http://me.example.com:8140/").to_return(redirect_to("http://me.example.com:8140/foo")) conn = create_connection(:redirect_limit => 0) expects_limit_exceeded(conn) end it "should follow the redirect once" do stub_request(:get, "http://me.example.com:8140/").to_return(redirect_to("http://me.example.com:8140/foo")) stub_request(:get, "http://me.example.com:8140/foo").to_return(redirect_to("http://me.example.com:8140/bar")) conn = create_connection(:redirect_limit => 1) expects_limit_exceeded(conn) end it "should raise an exception when the redirect limit is exceeded" do stub_request(:get, "http://me.example.com:8140/").to_return(redirect_to("http://me.example.com:8140/foo")) stub_request(:get, "http://me.example.com:8140/foo").to_return(redirect_to("http://me.example.com:8140/bar")) stub_request(:get, "http://me.example.com:8140/bar").to_return(redirect_to("http://me.example.com:8140/baz")) stub_request(:get, "http://me.example.com:8140/baz").to_return(redirect_to("http://me.example.com:8140/qux")) conn = create_connection(:redirect_limit => 3) expects_limit_exceeded(conn) end it 'raises an exception when the location header is missing' do stub_request(:get, "http://me.example.com:8140/").to_return(status: 302) expect { create_connection.get('/') }.to raise_error(Puppet::HTTP::ProtocolError, /Location response header is missing/) end end context "when response indicates an overloaded server" do def retry_after(datetime) stub_request(:get, url) .to_return(status: [503, 'Service Unavailable'], headers: {'Retry-After' => datetime}).then .to_return(status: 200) end it "should return a 503 response if Retry-After is not set" do stub_request(:get, url).to_return(status: [503, 'Service Unavailable']) result = subject.get('/foo') expect(result.code).to eq("503") end it "should return a 503 response if Retry-After is not convertible to an Integer or RFC 2822 Date" do retry_after('foo') expect { subject.get('/foo') }.to raise_error(Puppet::HTTP::ProtocolError, /Failed to parse Retry-After header 'foo'/) end it "should close the connection before sleeping" do retry_after('42') http1 = Net::HTTP.new(host, port) http1.use_ssl = true allow(http1).to receive(:started?).and_return(true) http2 = Net::HTTP.new(host, port) http2.use_ssl = true allow(http1).to receive(:started?).and_return(true) # The "with_connection" method is required to yield started connections pool = Puppet.runtime[:http].pool allow(pool).to receive(:with_connection).and_yield(http1).and_yield(http2) expect(http1).to receive(:finish).ordered expect(::Kernel).to receive(:sleep).with(42).ordered subject.get('/foo') end it "should sleep and retry if Retry-After is an Integer" do retry_after('42') expect(::Kernel).to receive(:sleep).with(42) result = subject.get('/foo') expect(result.code).to eq("200") end it "should sleep and retry if Retry-After is an RFC 2822 Date" do retry_after('Wed, 13 Apr 2005 15:18:05 GMT') now = DateTime.new(2005, 4, 13, 8, 17, 5, '-07:00') allow(DateTime).to receive(:now).and_return(now) expect(::Kernel).to receive(:sleep).with(60) result = subject.get('/foo') expect(result.code).to eq("200") end it "should sleep for no more than the Puppet runinterval" do retry_after('60') Puppet[:runinterval] = 30 expect(::Kernel).to receive(:sleep).with(30) subject.get('/foo') end it "should sleep for 0 seconds if the RFC 2822 date has past" do retry_after('Wed, 13 Apr 2005 15:18:05 GMT') expect(::Kernel).to receive(:sleep).with(0) subject.get('/foo') end end context "basic auth" do let(:auth) { { :user => 'user', :password => 'password' } } let(:creds) { [ 'user', 'password'] } it "is allowed in get requests" do stub_request(:get, url).with(basic_auth: creds) subject.get('/foo', nil, :basic_auth => auth) end it "is allowed in post requests" do stub_request(:post, url).with(basic_auth: creds) subject.post('/foo', 'data', nil, :basic_auth => auth) end it "is allowed in head requests" do stub_request(:head, url).with(basic_auth: creds) subject.head('/foo', nil, :basic_auth => auth) end it "is allowed in delete requests" do stub_request(:delete, url).with(basic_auth: creds) subject.delete('/foo', nil, :basic_auth => auth) end it "is allowed in put requests" do stub_request(:put, url).with(basic_auth: creds) subject.put('/foo', 'data', nil, :basic_auth => auth) end end it "sets HTTP User-Agent header" do puppet_ua = "Puppet/#{Puppet.version} Ruby/#{RUBY_VERSION}-p#{RUBY_PATCHLEVEL} (#{RUBY_PLATFORM})" stub_request(:get, url).with(headers: { 'User-Agent' => puppet_ua }) subject.get('/foo') end describe 'connection request errors' do it "logs and raises generic http errors" do generic_error = Net::HTTPError.new('generic error', double("response")) stub_request(:get, url).to_raise(generic_error) expect(Puppet).to receive(:log_exception).with(anything, /^.*failed.*: generic error$/) expect { subject.get('/foo') }.to raise_error(generic_error) end it "logs and raises timeout errors" do timeout_error = Net::OpenTimeout.new stub_request(:get, url).to_raise(timeout_error) expect(Puppet).to receive(:log_exception).with(anything, /^.*timed out .*after .* seconds/) expect { subject.get('/foo') }.to raise_error(timeout_error) end it "logs and raises eof errors" do eof_error = EOFError stub_request(:get, url).to_raise(eof_error) expect(Puppet).to receive(:log_exception).with(anything, /^.*interrupted after .* seconds$/) expect { subject.get('/foo') }.to raise_error(eof_error) end end end describe Puppet::Network::HTTP::Connection do it_behaves_like "an HTTP connection", described_class end end