diff --git a/moto/s3/models.py b/moto/s3/models.py
index 431c9c988..30ecf1595 100644
--- a/moto/s3/models.py
+++ b/moto/s3/models.py
@@ -175,11 +175,14 @@ class FakeMultipart(BaseModel):
count = 0
for pn, etag in body:
part = self.parts.get(pn)
- if part is None or part.etag != etag:
+ part_etag = None
+ if part is not None:
+ part_etag = part.etag.replace('"', '')
+ etag = etag.replace('"', '')
+ if part is None or part_etag != etag:
raise InvalidPart()
if last is not None and len(last.value) < UPLOAD_PART_MIN_SIZE:
raise EntityTooSmall()
- part_etag = part.etag.replace('"', '')
md5s.extend(decode_hex(part_etag)[0])
total.extend(part.value)
last = part
diff --git a/tests/test_s3/test_s3.py b/tests/test_s3/test_s3.py
index 9f37791cb..6de79f874 100644
--- a/tests/test_s3/test_s3.py
+++ b/tests/test_s3/test_s3.py
@@ -225,6 +225,29 @@ def test_multipart_invalid_order():
bucket.complete_multipart_upload.when.called_with(
multipart.key_name, multipart.id, xml).should.throw(S3ResponseError)
+@mock_s3_deprecated
+@reduced_min_part_size
+def test_multipart_etag_quotes_stripped():
+ # Create Bucket so that test can run
+ conn = boto.connect_s3('the_key', 'the_secret')
+ bucket = conn.create_bucket('mybucket')
+
+ multipart = bucket.initiate_multipart_upload("the-key")
+ part1 = b'0' * REDUCED_PART_SIZE
+ etag1 = multipart.upload_part_from_file(BytesIO(part1), 1).etag
+ # last part, can be less than 5 MB
+ part2 = b'1'
+ etag2 = multipart.upload_part_from_file(BytesIO(part2), 2).etag
+ # Strip quotes from etags
+ etag1 = etag1.replace('"','')
+ etag2 = etag2.replace('"','')
+ xml = "{0}{1}"
+ xml = xml.format(1, etag1) + xml.format(2, etag2)
+ xml = "{0}".format(xml)
+ bucket.complete_multipart_upload.when.called_with(
+ multipart.key_name, multipart.id, xml).should_not.throw(S3ResponseError)
+ # we should get both parts as the key contents
+ bucket.get_key("the-key").etag.should.equal(EXPECTED_ETAG)
@mock_s3_deprecated
@reduced_min_part_size