Repository: localstack/localstack

Issue:
s3api delete-objects on a non-existent key throws errors
Deleting a non-existent key through s3api's delete-objects should return a valid response..

```
aws s3api delete-objects --bucket my-bucket --delete '{\"Objects\":[{\"Key\":\"my-key\"}]}'
```
Gives...
```
{
    "Deleted": [
        {
            "Key": "my-key"
        }
    ]
}
```

Deleting a non-existent key through s3api's delete-objects on LocalStack returns an error response without any additional information beyond the key.

```
aws s3api delete-objects --bucket my-bucket --delete '{"Objects":[{"Key":"my-key"}]}' --endpoint-url http://localhost:4572
```
Gives...
```
{
    "ResponseMetadata": {
        "HTTPStatusCode": 200,
        "HTTPHeaders": {
            "server": "BaseHTTP/0.6 Python/3.7.5",
            "date": "Fri, 20 Dec 2019 16:02:39 GMT",
            "content-type": "application/xml; charset=utf-8",
            "content-length": "180",
            "access-control-allow-origin": "*",
            "last-modified": "Fri, 20 Dec 2019 16:02:39 GMT",
            "access-control-allow-methods": "HEAD,GET,PUT,POST,DELETE,OPTIONS,PATCH",
            "access-control-allow-headers": "authorization,content-type,content-md5,cache-control,x-amz-content-sha256,x-amz-date,x-amz-security-token,x-amz-user-agent,x-amz-target,x-amz-acl,x-amz-version-id,x-localstack-target,x-amz-tagging",
            "access-control-expose-headers": "x-amz-version-id"
        },
        "RetryAttempts": 0
    },
    "Errors": [
        {
            "Key": "my-key"
        }
    ]
}
```

Deleting an existent key seems to work fine.


Discussion:

Resolving patch:
diff --git a/localstack/services/s3/s3_listener.py b/localstack/services/s3/s3_listener.py
--- a/localstack/services/s3/s3_listener.py
+++ b/localstack/services/s3/s3_listener.py
@@ -367,6 +367,29 @@ def fix_range_content_type(bucket_name, path, headers, response):
         response.headers['Content-Type'] = content_type
 
 
+def fix_delete_objects_response(bucket_name, method, parsed_path, data, headers, response):
+    # Deleting non-existing keys should not result in errors.
+    # Fixes https://github.com/localstack/localstack/issues/1893
+    if not (method == 'POST' and parsed_path.query == 'delete' and '<Delete' in to_str(data or '')):
+        return
+    content = to_str(response._content)
+    if '<Error>' not in content:
+        return
+    result = xmltodict.parse(content).get('DeleteResult')
+    errors = result.get('Error')
+    errors = errors if isinstance(errors, list) else [errors]
+    deleted = result.get('Deleted')
+    if not isinstance(result.get('Deleted'), list):
+        deleted = result['Deleted'] = [deleted] if deleted else []
+    for entry in list(errors):
+        if set(entry.keys()) == set(['Key']):
+            errors.remove(entry)
+            deleted.append(entry)
+    if not errors:
+        result.pop('Error')
+    response._content = xmltodict.unparse({'DeleteResult': result})
+
+
 def remove_xml_preamble(response):
     """ Removes <?xml ... ?> from a response content """
     response._content = re.sub(r'^<\?[^\?]+\?>', '', response._content)
@@ -913,6 +936,7 @@ def return_response(self, method, path, data, headers, response):
             append_list_objects_marker(method, path, data, response)
             fix_location_constraint(response)
             fix_range_content_type(bucket_name, path, headers, response)
+            fix_delete_objects_response(bucket_name, method, parsed, data, headers, response)
 
             # Remove body from PUT response on presigned URL
             # https://github.com/localstack/localstack/issues/1317


Test patch:
diff --git a/localstack/ext/java/src/test/java/cloud/localstack/S3FeaturesTest.java b/localstack/ext/java/src/test/java/cloud/localstack/S3FeaturesTest.java
--- a/localstack/ext/java/src/test/java/cloud/localstack/S3FeaturesTest.java
+++ b/localstack/ext/java/src/test/java/cloud/localstack/S3FeaturesTest.java
@@ -3,30 +3,19 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotNull;
 
-import java.io.ByteArrayInputStream;
-import java.io.InputStream;
+import java.io.*;
 import java.nio.charset.StandardCharsets;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.UUID;
+import java.util.*;
+import java.util.stream.*;
 
 import org.junit.Assert;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
 import com.amazonaws.client.builder.AwsClientBuilder;
-import com.amazonaws.services.s3.AmazonS3;
-import com.amazonaws.services.s3.AmazonS3ClientBuilder;
-import com.amazonaws.services.s3.model.BucketLifecycleConfiguration;
-import com.amazonaws.services.s3.model.GetObjectRequest;
-import com.amazonaws.services.s3.model.ObjectMetadata;
-import com.amazonaws.services.s3.model.PutObjectRequest;
-import com.amazonaws.services.s3.model.PutObjectResult;
-import com.amazonaws.services.s3.model.S3Object;
-import com.amazonaws.services.s3.model.SSEAwsKeyManagementParams;
-import com.amazonaws.services.s3.model.Tag;
-import com.amazonaws.services.s3.model.lifecycle.LifecycleFilter;
-import com.amazonaws.services.s3.model.lifecycle.LifecycleTagPredicate;
+import com.amazonaws.services.s3.*;
+import com.amazonaws.services.s3.model.*;
+import com.amazonaws.services.s3.model.lifecycle.*;
 
 @RunWith(LocalstackTestRunner.class)
 public class S3FeaturesTest {
@@ -126,4 +115,37 @@ public void testMetadata() {
 		Assert.assertTrue(receivedMetadata.equals(originalMetadata) || receivedMetadata.equals(actualResult) );
 	}
 
+	@Test
+	public void testListNextBatchOfObjects() {
+		AmazonS3 s3Client = TestUtils.getClientS3();
+		String s3BucketName = UUID.randomUUID().toString();
+		s3Client.createBucket(s3BucketName);
+		s3Client.putObject(s3BucketName, "key1", "content");
+		s3Client.putObject(s3BucketName, "key2", "content");
+		s3Client.putObject(s3BucketName, "key3", "content");
+
+		ListObjectsRequest listObjectsRequest = new ListObjectsRequest()
+			.withBucketName(s3BucketName)
+			.withPrefix("")
+			.withDelimiter("/")
+			.withMaxKeys(1); // 1 Key per request
+
+		ObjectListing objectListing = s3Client.listObjects(listObjectsRequest);
+		List<Object> someObjList = new LinkedList<>();
+		someObjList.addAll(mapFilesToSomeObject(objectListing)); // puts at least 1 item into the list
+
+		while (objectListing.isTruncated()) {
+			objectListing = s3Client.listNextBatchOfObjects(objectListing);
+			someObjList.addAll(mapFilesToSomeObject(objectListing));
+		}
+		System.out.println("someObjList.size() " + someObjList.size());
+		assertEquals(3, someObjList.size());
+	}
+
+	private List<Object> mapFilesToSomeObject(ObjectListing objectListing) {
+		return objectListing.getObjectSummaries()
+			.stream()
+			.map(S3ObjectSummary::getKey)
+			.collect(Collectors.toList());
+	}
 }
diff --git a/tests/integration/test_s3.py b/tests/integration/test_s3.py
--- a/tests/integration/test_s3.py
+++ b/tests/integration/test_s3.py
@@ -370,6 +370,20 @@ def test_delete_object_tagging(self):
         # assert that the object still exists
         response = requests.get(url, verify=False)
         self.assertEqual(response.status_code, 200)
+        # clean up
+        self._delete_bucket(bucket_name, [object_key])
+
+    def test_delete_non_existing_keys(self):
+        bucket_name = 'test-%s' % short_uid()
+        self.s3_client.create_bucket(Bucket=bucket_name)
+        object_key = 'test-key'
+        self.s3_client.put_object(Bucket=bucket_name, Key=object_key, Body='something')
+        response = self.s3_client.delete_objects(Bucket=bucket_name,
+            Delete={'Objects': [{'Key': object_key}, {'Key': 'dummy1'}, {'Key': 'dummy2'}]})
+        self.assertEqual(len(response['Deleted']), 3)
+        self.assertNotIn('Errors', response)
+        # clean up
+        self._delete_bucket(bucket_name)
 
     def test_bucket_exists(self):
         # Test setup
@@ -632,10 +646,11 @@ def _get_test_queue_message_count(self, queue_url):
             QueueUrl=queue_url, AttributeNames=['ApproximateNumberOfMessages'])
         return queue_attributes['Attributes']['ApproximateNumberOfMessages']
 
-    def _delete_bucket(self, bucket_name, keys):
+    def _delete_bucket(self, bucket_name, keys=[]):
         keys = keys if isinstance(keys, list) else [keys]
         objects = [{'Key': k} for k in keys]
-        self.s3_client.delete_objects(Bucket=bucket_name, Delete={'Objects': objects})
+        if objects:
+            self.s3_client.delete_objects(Bucket=bucket_name, Delete={'Objects': objects})
         self.s3_client.delete_bucket(Bucket=bucket_name)
 
     def _perform_multipart_upload(self, bucket, key, data=None, zip=False, acl=None):