Repository: getmoto/moto
Pull request: S3: Do not unquote the key two times on multipart upload complete
Current implementation unquotes key two times, firstly in `self.backend.put_object` and then in `self.backend.put_object_acl` -> `self.get_object`. To fix this issue I have exposed `key_is_clean` in `put_object_acl` and set it to `True`.

Problem statement:
Motoserver multipart upload failure
There is the code I am running to save the file using multipart upload:

```
import boto3
from boto3.s3.transfer import TransferConfig

s3 = boto3.client('s3')
s3.create_bucket(Bucket='test')
s3.upload_fileobj(
    io.BytesIO(b'123'),
    'test',
    'ns/id=A%20B/9d985da515104b58adcf01acf79f6892-0.raw',
    Config=TransferConfig(multipart_threshold=1),
)
```
Client error:
```
botocore.errorfactory.NoSuchKey: An error occurred (NoSuchKey) when calling the CompleteMultipartUpload operation: The specified key does not exist.
```

Motoserver logs(404 for POST request):
```
2023-05-03 11:44:47 172.17.0.1 - - [03/May/2023 08:44:47] "PUT /test HTTP/1.1" 200 -
2023-05-03 11:44:47 172.17.0.1 - - [03/May/2023 08:44:47] "POST /test/ns/id%3DA%2520B/9d985da515104b58adcf01acf79f6892-0.raw?uploads HTTP/1.1" 200 -
2023-05-03 11:44:47 172.17.0.1 - - [03/May/2023 08:44:47] "PUT /test/ns/id%3DA%2520B/9d985da515104b58adcf01acf79f6892-0.raw?uploadId=XBHMvHpDsU8xlBSIhfRZetKf0Vum7iUWzdL7eVsgxpsVTZlDchbejBsCQ&partNumber=1 HTTP/1.1" 200 -
2023-05-03 11:44:47 172.17.0.1 - - [03/May/2023 08:44:47] "POST /test/ns/id%3DA%2520B/9d985da515104b58adcf01acf79f6892-0.raw?uploadId=XBHMvHpDsU8xlBSIhfRZetKf0Vum7iUWzdL7eVsgxpsVTZlDchbejBsCQ HTTP/1.1" 404 -
```

It works fine if you don't use A%20B, but just AB for example:
```
import boto3
from boto3.s3.transfer import TransferConfig

s3 = boto3.client('s3')
s3.create_bucket(Bucket='test')
s3.upload_fileobj(
    io.BytesIO(b'123'),
    'test',
    'ns/id=AB/9d985da515104b58adcf01acf79f6892-0.raw',
    Config=TransferConfig(multipart_threshold=1),
)
```

Could you please explain what is wrong with that path?

Hints from issue discussion:

Commit message: Do not unquote the key two times on multipart upload complete

Patch to review:
diff --git a/moto/s3/models.py b/moto/s3/models.py
index e6365dabf46f..930d7625697e 100644
--- a/moto/s3/models.py
+++ b/moto/s3/models.py
@@ -1888,9 +1888,13 @@ def put_object(
         return new_key
 
     def put_object_acl(
-        self, bucket_name: str, key_name: str, acl: Optional[FakeAcl]
+        self,
+        bucket_name: str,
+        key_name: str,
+        acl: Optional[FakeAcl],
+        key_is_clean: bool = False,
     ) -> None:
-        key = self.get_object(bucket_name, key_name)
+        key = self.get_object(bucket_name, key_name, key_is_clean=key_is_clean)
         # TODO: Support the XML-based ACL format
         if key is not None:
             key.set_acl(acl)
diff --git a/moto/s3/responses.py b/moto/s3/responses.py
index 10270de8ffa2..da42bac69880 100644
--- a/moto/s3/responses.py
+++ b/moto/s3/responses.py
@@ -2173,7 +2173,12 @@ def _key_response_post(
             )
             key.set_metadata(multipart.metadata)
             self.backend.set_key_tags(key, multipart.tags)
-            self.backend.put_object_acl(bucket_name, key.name, multipart.acl)
+            self.backend.put_object_acl(
+                bucket_name=bucket_name,
+                key_name=key.name,
+                acl=multipart.acl,
+                key_is_clean=True,
+            )
 
             template = self.response_template(S3_MULTIPART_COMPLETE_RESPONSE)
             headers: Dict[str, Any] = {}
diff --git a/tests/test_s3/test_s3_multipart.py b/tests/test_s3/test_s3_multipart.py
index b152c88bad0c..551ca9cd1894 100644
--- a/tests/test_s3/test_s3_multipart.py
+++ b/tests/test_s3/test_s3_multipart.py
@@ -148,6 +148,47 @@ def test_multipart_upload():
     response["Body"].read().should.equal(part1 + part2)
 
 
+@mock_s3
+@reduced_min_part_size
+def test_multipart_upload_with_encoded_char():
+    s3 = boto3.resource("s3", region_name=DEFAULT_REGION_NAME)
+    client = boto3.client("s3", region_name=DEFAULT_REGION_NAME)
+    s3.create_bucket(Bucket="foobar")
+
+    part1 = b"0" * REDUCED_PART_SIZE
+    part2 = b"1"
+    mp = client.create_multipart_upload(Bucket="foobar", Key="the%20key")
+    up1 = client.upload_part(
+        Body=BytesIO(part1),
+        PartNumber=1,
+        Bucket="foobar",
+        Key="the%20key",
+        UploadId=mp["UploadId"],
+    )
+    up2 = client.upload_part(
+        Body=BytesIO(part2),
+        PartNumber=2,
+        Bucket="foobar",
+        Key="the%20key",
+        UploadId=mp["UploadId"],
+    )
+
+    client.complete_multipart_upload(
+        Bucket="foobar",
+        Key="the%20key",
+        MultipartUpload={
+            "Parts": [
+                {"ETag": up1["ETag"], "PartNumber": 1},
+                {"ETag": up2["ETag"], "PartNumber": 2},
+            ]
+        },
+        UploadId=mp["UploadId"],
+    )
+    # we should get both parts as the key contents
+    response = client.get_object(Bucket="foobar", Key="the%20key")
+    response["Body"].read().should.equal(part1 + part2)
+
+
 @mock_s3
 @reduced_min_part_size
 def test_multipart_upload_out_of_order():


Code review comments:
diff_hunk: @@ -148,6 +148,47 @@ def test_multipart_upload():
     response["Body"].read().should.equal(part1 + part2)
 
 
+@mock_s3
+@reduced_min_part_size
+def test_multipart_upload_with_encoded_char():
path: tests/test_s3/test_s3_multipart.py
text: @user1:
This is the same test as `test_multipart_upload`, right? 
I think it's cleaner to just parametrize that test, using `pytest.mark.parametrize`

There are other tests in this file using the same fixture, so you can use those as a guide. Let me know if you run into any issues.

@author:
Makes sense. Done.