# frozen_string_literal: true require_relative 'spec_helper' require 'stringio' require 'tempfile' module Aws module S3 describe Client do let(:client) { Client.new } before(:each) do Aws.config[:s3] = { region: 'us-east-1', credentials: Credentials.new('akid', 'secret'), retry_backoff: ->(*args) {} } end after(:each) do Aws.config = {} S3::BUCKET_REGIONS.clear end it 'raises an error when region is missing' do expect do Client.new(region: nil) end.to raise_error(Aws::Errors::MissingRegionError) end it 'supports empty stub with token set to nil' do s3 = Client.new(stub_responses: true) resp = s3.list_objects(bucket: 'foo') expect(resp.next_marker).to be(nil) end describe 'request ids' do it 'populates request id and host id in the response context' do s3 = Client.new(stub_responses: true) s3.handle(step: :send) do |context| context.http_response.signal_done( status_code: 200, headers: { 'x-amz-id-2' => 'H0vUEO2f4PyWtNjgcb3TSdyHaie8j4IgnuKIW2'\ 'rw0nS41rawnLDzkf+PKXmmt/uEi4bzvNMr72o=', 'x-amz-request-id' => 'BE9C18E622969B17' }, body: '' ) Seahorse::Client::Response.new(context: context) end resp = s3.list_buckets expect(resp.context[:request_id]).to eq('BE9C18E622969B17') expect(resp.context[:s3_host_id]).to eq( 'H0vUEO2f4PyWtNjgcb3TSdyHaie8j4IgnuKIW2'\ 'rw0nS41rawnLDzkf+PKXmmt/uEi4bzvNMr72o=' ) end end describe 'endpoints' do it 'preserves custom endpoints' do client = Client.new( stub_responses: true, endpoint: 'http://custom.domain/path/prefix', force_path_style: true ) resp = client.put_object(bucket: 'bucket-name', key: 'key/path') expect(resp.context.http_request.endpoint.to_s).to eq( 'http://custom.domain/path/prefix/bucket-name/key/path' ) end it 'resolves correctly for gov-cloud' do s3 = Client.new(region: 'us-gov-west-1') expect(s3.config.endpoint.to_s).to eq( 'https://s3.us-gov-west-1.amazonaws.com' ) end end describe 'http errors' do { 304 => Errors::NotModified, 400 => Errors::BadRequest, 403 => Errors::Forbidden, 404 => Errors::NotFound, 412 => Errors::PreconditionFailed, 500 => Errors::Http500Error }.each_pair do |status_code, error_class| it "raises #{error_class} for HTTP #{status_code} responses" do client = Client.new(stub_responses: true) client.handle(step: :send) do |context| context.http_response.signal_done( status_code: status_code, headers: {}, body: '' ) Seahorse::Client::Response.new(context: context) end expect do client.head_object(bucket: 'b', key: 'k') end.to raise_error(error_class) end end end describe 'permanent redirect error' do it 'includes endpoint and bucket in PermanentRedirect' do client = Client.new(stub_responses: true) client.handle(step: :send) do |context| context.http_response.signal_done( status_code: 301, headers: { 'x-amz-bucket-region' => 'us-peccy-1' }, body: <<-BODY) PermanentRedirect Error message. http://foo.com bucket BODY Seahorse::Client::Response.new(context: context) end expect do client.list_objects_v2(bucket: 'bucket') end.to raise_error(Errors::PermanentRedirect) do |error| expect(error.message).to eq('Error message.') expect(error.data.endpoint).to eq('http://foo.com') expect(error.data.bucket).to eq('bucket') expect(error.data.region).to eq('us-peccy-1') end end end describe 'unlinked tempfiles' do it 'can put an unlinked file descriptor' do data = '.' * 1024 * 1024 tmpfile = Tempfile.new('tmp') tmpfile.write(data) tmpfile.rewind tmpfile.unlink s3 = Client.new(stub_responses: true) resp = s3.put_object(bucket: 'bucket', key: 'key', body: tmpfile) expect(resp.context.http_request.body_contents).to eq(data) end end describe 'closed files' do it 'accepts closed File objects' do closed_file = File.open(__FILE__, 'rb') closed_file.close client = Client.new(stub_responses: true) resp = client.put_object( bucket: 'aws-sdk', key: 'key', body: closed_file ) body = resp.context.http_request.body expect(body).to be_kind_of(File) expect(body.path).to eq(__FILE__) expect(body).not_to be(closed_file) expect(body.closed?).to be(true) end it 'accepts closed Tempfile objects' do tmpfile = Tempfile.new('tmpfile') tmpfile.write('abc') tmpfile.close client = Client.new(stub_responses: true) resp = client.put_object(bucket: 'aws-sdk', key: 'key', body: tmpfile) body = resp.context.http_request.body expect(body).to be_kind_of(File) expect(body.path).to eq(tmpfile.path) expect(body).not_to be(tmpfile) expect(body.closed?).to be(true) end end describe 'empty body error responses' do it 'creates an error class from empty body responses' do client.handle(step: :send) do |context| context.http_response.signal_done( status_code: 500, headers: {}, body: '' ) Seahorse::Client::Response.new(context: context) end expect do client.head_bucket(bucket: 'aws-sdk') end.to raise_error(S3::Errors::Http500Error) end end describe 'signature version' do %w[ us-west-1 us-west-2 ap-northeast-1 ap-southeast-1 ap-southeast-2 sa-east-1 eu-west-1 us-gov-west-1 cn-north-1 eu-central-1 unknown-region ].each do |region| it "defaults signature version 4 for #{region}" do client = Client.new(stub_responses: true, region: region) resp = client.head_object(bucket: 'name', key: 'key') expect(resp.context.http_request.headers['authorization']).to match( 'AWS4-HMAC-SHA256' ) end it "forces v4 signing, even for PUT object in #{region}" do client = Client.new(stub_responses: true, region: region) resp = client.put_object(bucket: 'name', key: 'key', body: 'data') expect(resp.context.http_request.headers['authorization']).to match( 'AWS4-HMAC-SHA256' ) end it 'forces v4 signing when aws:kms used for server side encryption' do client = Client.new(stub_responses: true, region: region) resp = client.put_object( bucket: 'name', key: 'key', server_side_encryption: 'aws:kms', body: 'data' ) expect(resp.context.http_request.headers['authorization']).to match( 'AWS4-HMAC-SHA256' ) end end it 'uses signature version 4 when aws:kms used for sse' do client = Client.new(stub_responses: true, region: 'us-east-1') resp = client.put_object( bucket: 'name', key: 'key', server_side_encryption: 'aws:kms', body: 'data' ) expect(resp.context.http_request.headers['authorization']).to match( 'AWS4-HMAC-SHA256' ) end it 'raises a runtime error on unsupported signature version' do expect do Client.new( signature_version: 'v2', stub_responses: true, region: 'us-east-1' ) end.to raise_error(ArgumentError, /unsupported/) end end describe 'https required for sse cpk' do it 'raises a runtime error when attempting SSE CPK over HTTP' do s3 = Client.new(endpoint: 'http://s3.amazonaws.com') # put_object expect do s3.put_object( bucket: 'aws-sdk', key: 'key', sse_customer_key: 'secret' ) end.to raise_error(/HTTPS/) # copy_object_source expect do s3.copy_object( bucket: 'aws-sdk', key: 'key', copy_source: 'bucket#key', copy_source_sse_customer_key: 'secret' ) end.to raise_error(/HTTPS/) end end describe 'endpoints' do it 'resolves correctly for gov-cloud' do s3 = Client.new(region: 'us-gov-west-1') expect(s3.config.endpoint.to_s).to eq( 'https://s3.us-gov-west-1.amazonaws.com' ) end end describe 'invalid Expires header' do %w[get_object head_object].each do |method| it "correctly handled invalid Expires header for #{method}" do s3 = Client.new s3.handle(step: :send) do |context| context.http_response.signal_headers(200, 'Expires' => 'abc') context.http_response.signal_done Seahorse::Client::Response.new(context: context) end resp = s3.send(method, bucket: 'b', key: 'k') expect(resp.expires).to be(nil) expect(resp.expires_string).to eq('abc') end it 'accepts a stubbed Expires header as a Time value' do now = Time.at(Time.now.to_i) s3 = Client.new( stub_responses: { method.to_sym => { expires: now } } ) resp = s3.send(method, bucket: 'b', key: 'k') expect(resp.expires).to eq(now) expect(resp.expires_string).to eq(now.httpdate) end it 'accepts a stubbed Expires header as String value' do s3 = Client.new( stub_responses: { method.to_sym => { expires_string: 'abc' } } ) resp = s3.send(method, bucket: 'b', key: 'k') expect(resp.expires).to be(nil) expect(resp.expires_string).to eq('abc') end end end describe '#create_bucket' do it 'omits location constraint for the classic region' do s3 = Client.new(region: 'us-east-1') s3.handle(step: :send) do |context| context.http_response.status_code = 200 Seahorse::Client::Response.new(context: context) end resp = s3.create_bucket(bucket: 'aws-sdk') expect(resp.context.http_request.body_contents).to eq('') end it 'populates the bucket location constraint for non-classic regions' do s3 = Client.new(region: 'us-west-2') s3.handle(step: :send) do |context| context.http_response.status_code = 200 Seahorse::Client::Response.new(context: context) end resp = s3.create_bucket(bucket: 'aws-sdk') expect(resp.context.http_request.body_contents.strip) .to eq(<<-XML.gsub(/(^\s+|\n)/, '')) us-west-2 XML end it 'does not overide bucket location constraint params' do s3 = Client.new(region: 'eu-west-1') s3.handle(step: :send) do |context| context.http_response.status_code = 200 Seahorse::Client::Response.new(context: context) end resp = s3.create_bucket( bucket: 'aws-sdk', create_bucket_configuration: { location_constraint: 'EU' } ) expect(resp.context.http_request.body_contents.strip) .to eq(<<-XML.gsub(/(^\s+|\n)/, '')) EU XML end end describe '#delete_bucket' do it 'correctly unmarshals errors' do client = Client.new(stub_responses: true) client.handle(step: :send) do |context| context.http_response.signal_done( status_code: 409, headers: {}, body: <<-XML.strip BucketNotEmpty The bucket you tried to delete is not empty aws-sdk 740BE6AB575EACED MQVg1RMI+d93Hps1E8qpIuDb9Gd2TzkDhm0wE40981DjxSHP1UfLBB7qOAlwAqJB XML ) Seahorse::Client::Response.new(context: context) end expect do client.delete_bucket(bucket: 'name') end.to raise_error( Errors::BucketNotEmpty, 'The bucket you tried to delete is not empty' ) end end describe '#get_bucket_location' do it 'can parse the location constraint XML' do client = Client.new(stub_responses: true) client.handle(step: :send) do |context| context.http_response.signal_done( status_code: 200, headers: {}, body: <<-XML.strip EU XML ) Seahorse::Client::Response.new(context: context) end resp = client.get_bucket_location(bucket: 'name') expect(resp.location_constraint).to eq('EU') end it 'returns an empty string when no constraint is present' do client = Client.new(stub_responses: true) client.handle(step: :send) do |context| context.http_response.signal_done( status_code: 200, headers: {}, body: <<-XML.strip XML ) Seahorse::Client::Response.new(context: context) end resp = client.get_bucket_location(bucket: 'name') expect(resp.location_constraint).to eq('') end end describe '#head_bucket' do it 'uses path style addressing for DNS incompatible bucket names' do client = Client.new(stub_responses: true) resp = client.head_bucket(bucket: 'Bucket123') expect(resp.context.http_request.endpoint.path).to eq('/Bucket123/') end end describe '#list_objects' do it 'raises an error of the bucket name contains a forward slash' do client = Client.new(stub_responses: true) expect do client.list_objects(bucket: 'bucket-name/key-prefix') end.to raise_error( ArgumentError, 'bucket name must not contain a forward-slash (/)' ) end it 'request url encoded keys and decodes them by default' do client.handle(step: :send) do |context| context.http_response.signal_done( status_code: 200, headers: {}, body: <<-XML.strip) a%26 b%26 c%26 d%26 e%26 f%26 XML Seahorse::Client::Response.new(context: context) end resp = client.list_objects(bucket: 'aws-sdk') expect( resp.context.http_request.endpoint.query ).to include('encoding-type=url') expect(resp.context.params[:encoding_type]).to eq('url') expect(resp.data.to_h).to eq( prefix: 'a&', delimiter: 'b&', marker: 'c&', next_marker: 'd&', contents: [{ key: 'e&' }], common_prefixes: [{ prefix: 'f&' }] ) end it 'skips url decoding when the user specifies the encoding' do client.handle(step: :send) do |context| context.http_response.signal_done( status_code: 200, headers: {}, body: <<-XML.strip) a%26 XML Seahorse::Client::Response.new(context: context) end resp = client.list_objects(bucket: 'aws-sdk', encoding_type: 'url') expect(resp.data.contents.map(&:key)).to eq(['a%26']) end end describe '#list_object_versions' do it 'request url encoded keys and decodes them by default' do client.handle(step: :send) do |context| context.http_response.signal_done( status_code: 200, headers: {}, body: <<-XML.strip) a%26 b%26 c%26 d%26 e%26 f%26 g%26 XML Seahorse::Client::Response.new(context: context) end resp = client.list_object_versions(bucket: 'aws-sdk') expect(resp.context.params[:encoding_type]).to eq('url') expect(resp.data.to_h).to eq( prefix: 'a&', delimiter: 'b&', key_marker: 'c&', next_key_marker: 'd&', versions: [{ key: 'e&' }], delete_markers: [{ key: 'f&' }], common_prefixes: [{ prefix: 'g&' }] ) end end describe '#list_multipart_uploads' do it 'request url encoded keys and decodes them by default' do client.handle(step: :send) do |context| context.http_response.signal_done( status_code: 200, headers: {}, body: <<-XML.strip) a%26 b%26 c%26 d%26 e%26 f%26 XML Seahorse::Client::Response.new(context: context) end resp = client.list_multipart_uploads(bucket: 'aws-sdk') expect(resp.context.params[:encoding_type]).to eq('url') expect(resp.data.to_h).to eq( prefix: 'a&', delimiter: 'b&', key_marker: 'c&', next_key_marker: 'd&', uploads: [{ key: 'e&' }], common_prefixes: [{ prefix: 'f&' }] ) end end describe '#put_object_acl' do it 'builds the ACL xml from request params' do client = Client.new(stub_responses: true) resp = client.put_object_acl( bucket: 'bucket', key: 'key', access_control_policy: { grants: [ { grantee: { display_name: 'name', type: 'CanonicalUser' }, permission: 'READ' } ] } ) expect(resp.context.http_request.body_contents).to eq(<<-XML.gsub(/(^\s+|\n)/, '')) name READ XML end end describe '#put_object' do it 'populates the content-type header when given' do client = Client.new(stub_responses: true) resp = client.put_object( bucket: 'b', key: 'k', body: 'test', content_type: 'text/plain' ) expect( resp.context.http_request.headers['content-type'] ).to eq('text/plain') end end describe '#put_object_acl' do it 'correct decodes url keys' do client.handle(step: :send) do |context| context.http_response.signal_done( status_code: 200, headers: {}, body: <<-XML) prefix+suffix prefix%2Bsuffix prefix%20suffix XML Seahorse::Client::Response.new(context: context) end resp = client.list_objects(bucket: 'aws-sdk') expect(resp.contents.map(&:key)).to eq( ['prefix suffix', 'prefix+suffix', 'prefix suffix'] ) end end describe 'truncated body checks' do it 'accepts responses where content-length equals bytes received' do stub_request(:get, 'https://bucket.s3.amazonaws.com/key') .to_return( status: 200, body: 'data', headers: { 'content-length' => '4' } ) expect do client.get_object(bucket: 'bucket', key: 'key') end.not_to raise_error end it 'retries requests when bytes are less than content-length' do stub_request(:get, 'https://bucket.s3.amazonaws.com/key') .to_return( status: 200, body: 'dat', headers: { 'content-length' => '4' } ) .to_return( status: 200, body: 'data', headers: { 'content-length' => '4' } ) resp = client.get_object(bucket: 'bucket', key: 'key') expect(resp.context.retries).to eq(1) expect(resp.body.read).to eq('data') end it 'raises an error if fewer than content-length bytes are received' do stub_request(:get, 'https://bucket.s3.amazonaws.com/key') .to_return( status: 200, body: 'dat', headers: { 'content-length' => '4' } ) msg = 'http response body truncated, expected '\ '4 bytes, received 3 bytes' expect do client.get_object(bucket: 'bucket', key: 'key') end.to raise_error(Seahorse::Client::NetworkingError, msg) end it 'does not check content-length when header not present' do stub_request(:head, 'https://bucket.s3.amazonaws.com/key') .to_return(status: 200, headers: {}) expect do client.head_object(bucket: 'bucket', key: 'key') end.not_to raise_error end end describe '#wait_until' do it 'returns true when the :bucket_exists waiter receives a 301' do stub_request(:head, 'https://bucket.s3.amazonaws.com') .to_return(status: 301) expect( client.wait_until(:bucket_exists, bucket: 'bucket') ).not_to be(nil) end end { complete_multipart_upload: { upload_id: 'upload-id' }, copy_object: { copy_source: 'bucket/key' }, upload_part_copy: { upload_id: 'upload-id', copy_source: 'bucket/key', part_number: 1 } }.each do |operation_name, params| describe "#{operation_name} response handling" do it 'handles 200 http response errors' do client.handlers.remove( Seahorse::Client::Plugins::RaiseResponseErrors::Handler ) client.handle(step: :send) do |context| context.http_response.signal_headers(200, {}) context.http_response.signal_data(<<-XML.strip) InternalError We encountered an internal error. Please try again. 656c76696e6727732072657175657374 Uuag1LuByRx9e6j5Onimru9pO4ZVKnJ2Qz7/C1NPcfTWAtRPfTaOFg== XML context.http_response.signal_done Seahorse::Client::Response.new(context: context) end resp = client.send(operation_name, { bucket: 'bucket', key: 'key' }.merge(params)) expect(resp.error).to be_kind_of(S3::Errors::InternalError) expect(resp.context.retries).to eq(3) expect(resp.data).to be(nil) end it 'handles 200 http response with incomplete body as error' do client.handlers.remove( Seahorse::Client::Plugins::RaiseResponseErrors::Handler ) client.handle(step: :send) do |context| context.http_response.signal_headers(200, {}) context.http_response.signal_data("\r\n") context.http_response.signal_done Seahorse::Client::Response.new(context: context) end resp = client.send(operation_name, { bucket: 'bucket', key: 'key' }.merge(params)) expect(resp.error).to be_kind_of(Seahorse::Client::NetworkingError) expect(resp.context.retries).to eq(3) expect(resp.data).to be(nil) end end end context 'metadata stubbing' do it 'returns metadata from head operations' do stub_client = S3::Client.new( stub_responses: { head_object: { metadata: { 'custom_key' => 'abc' } } } ) resp = stub_client.head_object(bucket: 'b', key: 'k') expect(resp.metadata).to eq('custom_key' => 'abc') end end end end end