# 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