Repository: getmoto/moto

Issue:
Multipart upload tags not applied
When creating an object in S3 with multi-part uploads, tags don't get applied for `create_multipart_upload`.

## MWE

```
import boto3
import urllib.parse
import os
from moto import mock_s3

bucket = 'mybucket'
key = 'test/multipartuploadtag/file.txt'
tags = {'a': 'b'}

if True:
    mock_s3().start()
    
    boto3.client('s3').create_bucket(
        Bucket=bucket,
        CreateBucketConfiguration={
            'LocationConstraint': 'ap-southeast-2'
        },
    )

client = boto3.client('s3')

response = client.create_multipart_upload(
    Bucket=bucket,
    Key=key,
    Tagging=urllib.parse.urlencode(tags)
)
u = boto3.resource('s3').MultipartUpload(bucket, key, response['UploadId'])
parts = [{'ETag': u.Part(i).upload(Body=os.urandom(5 * (2 ** 20)))["ETag"], 'PartNumber': i} for i in range(1,3)]

u.complete(
    MultipartUpload={
        'Parts': parts
    }
)

# check tags
response = client.get_object_tagging(
    Bucket=bucket,
    Key=key
)
actual = {t['Key']: t['Value'] for t in response.get('TagSet', [])}
assert actual != {}
assert actual == tags
```

## Expected behavior

Script runs without throwing an assertion error.

With `boto3` and no `moto` (change `if True` to `if False`), the script passes.

Tags passed to `create_multipart_upload` are present once the upload is finished.

## Actual behavior (with moto)

Script throws an assertion error. The object does not have any tags once upload is complete.




Discussion:
Thanks for providing the MWE @mdavis-xyz - doesn't look like this is supported yet
How does this normally get done?

I can't see a `tagging` argument in [the moto implementation of put object](https://github.com/spulec/moto/blob/7e3db1ecac75428794d670ff79a2bd389276bd77/moto/s3/models.py#L1557) either. But from memory that does work. (Although I haven't tested that recently, so I could be wrong.)
It _should_ be part of the put_object call to the backend. But for some requests, tagging is handled from [the response.py file](https://github.com/spulec/moto/blob/7e3db1ecac75428794d670ff79a2bd389276bd77/moto/s3/responses.py#L1532). 


Resolving patch:
diff --git a/moto/s3/models.py b/moto/s3/models.py
--- a/moto/s3/models.py
+++ b/moto/s3/models.py
@@ -330,9 +330,11 @@ def is_locked(self):
 
 
 class FakeMultipart(BaseModel):
-    def __init__(self, key_name, metadata):
+    def __init__(self, key_name, metadata, storage=None, tags=None):
         self.key_name = key_name
         self.metadata = metadata
+        self.storage = storage
+        self.tags = tags
         self.parts = {}
         self.partlist = []  # ordered list of part ID's
         rand_b64 = base64.b64encode(os.urandom(UPLOAD_ID_BYTES))
@@ -1834,9 +1836,10 @@ def is_truncated(self, bucket_name, multipart_id, next_part_number_marker):
         bucket = self.get_bucket(bucket_name)
         return len(bucket.multiparts[multipart_id].parts) >= next_part_number_marker
 
-    def create_multipart_upload(self, bucket_name, key_name, metadata, storage_type):
-        multipart = FakeMultipart(key_name, metadata)
-        multipart.storage = storage_type
+    def create_multipart_upload(
+        self, bucket_name, key_name, metadata, storage_type, tags
+    ):
+        multipart = FakeMultipart(key_name, metadata, storage=storage_type, tags=tags)
 
         bucket = self.get_bucket(bucket_name)
         bucket.multiparts[multipart.id] = multipart
diff --git a/moto/s3/responses.py b/moto/s3/responses.py
--- a/moto/s3/responses.py
+++ b/moto/s3/responses.py
@@ -2009,9 +2009,10 @@ def _key_response_post(self, request, body, bucket_name, query, key_name):
 
         if body == b"" and "uploads" in query:
             metadata = metadata_from_headers(request.headers)
+            tagging = self._tagging_from_headers(request.headers)
             storage_type = request.headers.get("x-amz-storage-class", "STANDARD")
             multipart_id = self.backend.create_multipart_upload(
-                bucket_name, key_name, metadata, storage_type
+                bucket_name, key_name, metadata, storage_type, tagging
             )
 
             template = self.response_template(S3_MULTIPART_INITIATE_RESPONSE)
@@ -2039,6 +2040,7 @@ def _key_response_post(self, request, body, bucket_name, query, key_name):
                 multipart=multipart,
             )
             key.set_metadata(multipart.metadata)
+            self.backend.set_key_tags(key, multipart.tags)
 
             template = self.response_template(S3_MULTIPART_COMPLETE_RESPONSE)
             headers = {}


Test patch:
diff --git a/tests/test_s3/test_s3_multipart.py b/tests/test_s3/test_s3_multipart.py
--- a/tests/test_s3/test_s3_multipart.py
+++ b/tests/test_s3/test_s3_multipart.py
@@ -1,6 +1,7 @@
 from botocore.exceptions import ClientError
 from moto import mock_s3
 import boto3
+import os
 import pytest
 import sure  # pylint: disable=unused-import
 
@@ -52,3 +53,30 @@ def test_boto3_multipart_part_size():
     err["Message"].should.equal(
         "The specified upload does not exist. The upload ID may be invalid, or the upload may have been aborted or completed."
     )
+
+
+@mock_s3
+def test_multipart_upload_with_tags():
+    bucket = "mybucket"
+    key = "test/multipartuploadtag/file.txt"
+    tags = "a=b"
+
+    client = boto3.client("s3", region_name="us-east-1")
+    client.create_bucket(Bucket=bucket)
+
+    response = client.create_multipart_upload(Bucket=bucket, Key=key, Tagging=tags)
+    u = boto3.resource("s3").MultipartUpload(bucket, key, response["UploadId"])
+    parts = [
+        {
+            "ETag": u.Part(i).upload(Body=os.urandom(5 * (2 ** 20)))["ETag"],
+            "PartNumber": i,
+        }
+        for i in range(1, 3)
+    ]
+
+    u.complete(MultipartUpload={"Parts": parts})
+
+    # check tags
+    response = client.get_object_tagging(Bucket=bucket, Key=key)
+    actual = {t["Key"]: t["Value"] for t in response.get("TagSet", [])}
+    actual.should.equal({"a": "b"})