Repository: localstack/localstack

Issue:
bug: Localstack drops empty string tag value and causes ElasticMQ exception
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current Behavior

If I run the following Terraform configuration against Localstack with ElasticMQ backend, then the resource creation hangs while ElasticMQ throws exceptions.

```terraform
terraform {
  required_providers {
    aws = "3.50.0"
  }
}

provider "aws" {
  region                      = "eu-west-1"

  access_key                  = "mock_access_key"
  secret_key                  = "mock_secret_key"
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true

  endpoints {
    sqs            = "http://localhost:4566"
  }
}

resource "aws_sqs_queue" "queue" {
  name  = "foobar"

  tags = {
    NoFoo = ""
  }
}
```

If I run `ncat -l localhost 4566` instead of Locastack, I can see the `Tag.1.Value=` is there.

```
POST / HTTP/1.1
Host: localhost:4566
User-Agent: APN/1.0 HashiCorp/1.0 Terraform/0.12.31 (+https://www.terraform.io) terraform-provider-aws/3.50.0 (+https://registry.terraform.io/providers/hashicorp/aws) aws-sdk-go/1.39.5 (go1.16; linux; amd64)
Content-Length: 382
Authorization: AWS4-HMAC-SHA256 Credential=mock_access_key/20210722/eu-west-1/sqs/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date, Signature=1a535809020db386c13b4b4169308bd79d81f47edb3922c9a59c4707d7b51061
Content-Type: application/x-www-form-urlencoded; charset=utf-8
X-Amz-Date: 20210722T133950Z
Accept-Encoding: gzip
Connection: close

Action=CreateQueue&Attribute.1.Name=DelaySeconds&Attribute.1.Value=0&Attribute.2.Name=MaximumMessageSize&Attribute.2.Value=262144&Attribute.3.Name=MessageRetentionPeriod&Attribute.3.Value=345600&Attribute.4.Name=ReceiveMessageWaitTimeSeconds&Attribute.4.Value=0&Attribute.5.Name=VisibilityTimeout&Attribute.5.Value=30&QueueName=foobar&Tag.1.Key=NoFoo&Tag.1.Value=&Version=2012-11-05
```

If I exec into the Localstack container and attach `tshark` on ElasticMQ's port then I can see the query param got dropped.
(`tshark -i lo -f "tcp port 52097" -e http.file_data -Tjson`)

```
"Action=CreateQueue&Attribute.1.Name=DelaySeconds&Attribute.1.Value=0&Attribute.2.Name=MaximumMessageSize&Attribute.2.Value=262144&Attribute.3.Name=MessageRetentionPeriod&Attribute.3.Value=345600&Attribute.4.Name=ReceiveMessageWaitTimeSeconds&Attribute.4.Value=0&Attribute.5.Name=VisibilityTimeout&Attribute.5.Value=30&QueueName=foobar&Tag.1.Key=NoFoo&Version=2012-11-05"
```

### Expected Behavior

The SQS queue should be created without error and the tag set with empty value (`""`). 

### How are you starting LocalStack?

With a `docker run` command

### Steps To Reproduce

#### How are you starting localstack (e.g., `bin/localstack` command, arguments, or `docker-compose.yml`)

    docker run --rm -it -p "4566:4566" -e "SERVICES=sqs" -e "DEBUG=1" -e "SQS_PROVIDER=elasticmq" localstack/localstack:0.12.15

#### Client commands (e.g., AWS SDK code snippet, or sequence of "awslocal" commands)

    terraform init && terraform apply -auto-approve // on the above Terraform snippet put in main.tf

### Environment

```markdown
- OS: Ubuntu 20.04
- LocalStack: 0.12.15
- Terraform: 1.0.2
```


### Anything else?

As ElasticMQ could handle this edge case more gracefully (at least respond with HTTP 400 and not HTTP 500) I also opened in issue there:  https://github.com/softwaremill/elasticmq/issues/511


Discussion:

Resolving patch:
diff --git a/localstack/services/sqs/sqs_listener.py b/localstack/services/sqs/sqs_listener.py
--- a/localstack/services/sqs/sqs_listener.py
+++ b/localstack/services/sqs/sqs_listener.py
@@ -290,7 +290,7 @@ def forward_request(self, method, path, data, headers):
                         )
 
             elif action == "SetQueueAttributes":
-                # TODO remove this function if we stop using ElasticMQ entirely
+                # TODO remove this function if we stop using ElasticMQ
                 queue_url = _queue_url(path, req_data, headers)
                 if SQS_BACKEND_IMPL == "elasticmq":
                     forward_attrs = _set_queue_attributes(queue_url, req_data)
@@ -300,7 +300,11 @@ def forward_request(self, method, path, data, headers):
                             method, path, headers, req_data, forward_attrs
                         )
 
+            elif action == "TagQueue":
+                req_data = self.fix_missing_tag_values(req_data)
+
             elif action == "CreateQueue":
+                req_data = self.fix_missing_tag_values(req_data)
                 changed_attrs = _fix_dlq_arn_in_attributes(req_data)
                 if changed_attrs:
                     return _get_attributes_forward_request(
@@ -457,6 +461,21 @@ def get_message_attributes_md5(cls, req_data):
 
         return message_attr_hash
 
+    # Fixes tags with empty strings as value
+    def fix_missing_tag_values(self, req_data):
+        keys_matched = []
+        for k, v in req_data.items():
+            match = re.match(r"^Tag\.(\d+)\.Key", k)
+            if match:
+                index = match.group(1)
+                tag_val = "Tag.{}.Value".format(index)
+                if tag_val not in req_data.keys():
+                    keys_matched.append(tag_val)
+        if keys_matched:
+            for tag_val in keys_matched:
+                req_data[tag_val] = ""
+        return req_data
+
 
 # instantiate listener
 UPDATE_SQS = ProxyListenerSQS()


Test patch:
diff --git a/tests/integration/test_sqs.py b/tests/integration/test_sqs.py
--- a/tests/integration/test_sqs.py
+++ b/tests/integration/test_sqs.py
@@ -1,6 +1,7 @@
 import datetime
 import json
 import os
+import random
 import time
 import unittest
 
@@ -58,6 +59,7 @@
 
 THIS_FOLDER = os.path.dirname(os.path.realpath(__file__))
 TEST_LAMBDA_ECHO_FILE = os.path.join(THIS_FOLDER, "lambdas", "lambda_echo.py")
+TEST_LAMBDA_TAGS = {"tag1": "value1", "tag2": "value2", "tag3": ""}
 
 TEST_MESSAGE_ATTRIBUTES = {
     "City": {
@@ -452,7 +454,8 @@ def test_get_specific_queue_attribute_response(self):
             AttributeNames=["MessageRetentionPeriod", "RedrivePolicy"],
         )
         supported_attribute_get = self.client.get_queue_attributes(
-            QueueUrl=queue_url, AttributeNames=["QueueArn"]
+            QueueUrl=queue_url,
+            AttributeNames=["QueueArn"],
         )
         # assertion
         self.assertTrue("MessageRetentionPeriod" in unsupported_attribute_get["Attributes"].keys())
@@ -886,31 +889,25 @@ def test_fifo_queue_send_multiple_messages_multiple_single_receives(self):
         # clean up
         self.client.delete_queue(QueueUrl=queue_url)
 
+    # Not the same to create a queue with tags than tagging an existing queue
+    def test_create_queue_with_tags(self):
+        queue_name = "queue-{}".format(short_uid())
+        response = self.client.create_queue(
+            QueueName=queue_name,
+            tags=TEST_LAMBDA_TAGS,
+        )
+        self.assertEqual(200, response["ResponseMetadata"]["HTTPStatusCode"])
+        self.check_tagged_queue(response["QueueUrl"])
+
     def test_tag_untag_queue(self):
         queue_name = "queue-{}".format(short_uid())
         queue_url = self.client.create_queue(QueueName=queue_name)["QueueUrl"]
-
         response = self.client.tag_queue(
-            QueueUrl=queue_url, Tags={"tag1": "value1", "tag2": "value2"}
+            QueueUrl=queue_url,
+            Tags=TEST_LAMBDA_TAGS,
         )
         self.assertEqual(200, response["ResponseMetadata"]["HTTPStatusCode"])
-
-        response = self.client.list_queue_tags(QueueUrl=queue_url)
-        self.assertEqual(200, response["ResponseMetadata"]["HTTPStatusCode"])
-        self.assertIn("tag1", response["Tags"])
-        self.assertIn("tag2", response["Tags"])
-
-        response = self.client.untag_queue(QueueUrl=queue_url, TagKeys=["tag2"])
-        self.assertEqual(200, response["ResponseMetadata"]["HTTPStatusCode"])
-
-        response = self.client.list_queue_tags(QueueUrl=queue_url)
-        self.assertIn("tag1", response["Tags"])
-        self.assertNotIn("tag2", response["Tags"])
-        self.assertEqual(200, response["ResponseMetadata"]["HTTPStatusCode"])
-
-        # clean up
-        self.client.untag_queue(QueueUrl=queue_url, TagKeys=["tag1"])
-        self.client.delete_queue(QueueUrl=queue_url)
+        self.check_tagged_queue(queue_url)
 
     def test_posting_to_queue_with_trailing_slash(self):
         queue_name = "queue-{}".format(short_uid())
@@ -1020,3 +1017,32 @@ def receive_dlq(self, queue_url, assert_error_details=False, assert_receive_coun
                 # probably in or around this commit:
                 # https://github.com/spulec/moto/commit/6da4905da940e25e317db60b7657ea632f58ef1d
                 # self.assertEqual(str(assert_receive_count), msg_attrs.get('ApproximateReceiveCount'))
+
+    def check_tagged_queue(self, queue_url):
+        response = self.client.list_queue_tags(QueueUrl=queue_url)
+        self.assertEqual(200, response["ResponseMetadata"]["HTTPStatusCode"])
+        tags_keys_as_list = list(TEST_LAMBDA_TAGS.keys())
+
+        for key in tags_keys_as_list:
+            self.assertIn(key, response["Tags"])
+
+        random_tag_key = random.choice(tags_keys_as_list)
+        # Pop random chosen tag key from list
+        tags_keys_as_list.pop(tags_keys_as_list.index(random_tag_key))
+
+        response = self.client.untag_queue(QueueUrl=queue_url, TagKeys=[random_tag_key])
+        self.assertEqual(200, response["ResponseMetadata"]["HTTPStatusCode"])
+
+        response = self.client.list_queue_tags(QueueUrl=queue_url)
+        self.assertEqual(200, response["ResponseMetadata"]["HTTPStatusCode"])
+        self.assertNotIn(random_tag_key, response["Tags"])
+
+        for key in tags_keys_as_list:
+            self.assertIn(key, response["Tags"])
+
+        # Clean up
+        self.client.untag_queue(
+            QueueUrl=queue_url,
+            TagKeys=tags_keys_as_list,
+        )
+        self.client.delete_queue(QueueUrl=queue_url)