Repository: jordaneremieff/mangum

Issue:
0.11.0 Regression - ALB/ELB - Properly handle multiple set cookies in output when multiValueHeaders is not set
This is a regression from 0.11.0. 

The 0.11.0 ALB/ELB code handled when there were multiple set-cookies headers in the response, but multiValueHeaders was not set on the load balancer. 

```python
if "elb" in event["requestContext"]:
  for key, value in message.get("headers", []):
      lower_key = key.decode().lower()
      if lower_key in multi_value_headers:
          multi_value_headers[lower_key].append(value.decode())
      else:
          multi_value_headers[lower_key] = [value.decode()]
  if "multiValueHeaders" not in event:
      # If there are multiple occurrences of headers, create case-mutated
      # variations: https://github.com/logandk/serverless-wsgi/issues/11
      for key, values in multi_value_headers.items():
          if len(values) > 1:
              for value, cased_key in zip(values, all_casings(key)):
                  headers[cased_key] = value
          elif len(values) == 1:
              headers[key] = values[0]
      multi_value_headers = {}
```
https://github.com/jordaneremieff/mangum/blob/0.11.0/mangum/protocols/http.py#L148-L157

Original issue in serverless-wsgi that this fix is based on: https://github.com/logandk/serverless-wsgi/issues/11.

In the recent refactor this case mutation code was removed, resulting in the following bug:

Headers returned by asgi application
```python
[
        (b'Content-Type', b'text/html; charset=utf-8'), 
        (b'Location', b'/app/'), 
        (b'X-Frame-Options', b'DENY'), 
        (b'Vary', b'Cookie'), 
        (b'Content-Length', b'0'), 
        (b'X-Content-Type-Options', b'nosniff'), 
        (b'Referrer-Policy', b'same-origin'), 
        (b'Set-Cookie', b'csrftoken=abc; expires=Fri, 15 Apr 2022 18:44:11 GMT; Max-Age=31449600; Path=/; SameSite=Lax'), 
        (b'Set-Cookie', b'sessionid=xyz; expires=Sat, 17 Apr 2021 18:44:11 GMT; HttpOnly; Max-Age=86400; Path=/; SameSite=Lax')
]
```

Headers returned by mangum
```python
    {
        'content-type': 'text/html; charset=utf-8', 
        'location': '/app/', 
        'x-frame-options': 'DENY', 
        'vary': 'Cookie', 
        'content-length': '0', 
        'x-content-type-options': 'nosniff', 
        'referrer-policy': 'same-origin'
    }
```

I suggest adding back the original fix if that seems appropriate to the alb handler. 

Discussion:
jurasofish: The use of different case to send multiple headers makes me a little nervous.
I would say it's undefined - or at least unclear - how ALB handles that.
I've just opened a case with AWS support to ask about it.

jordaneremieff: Thanks @nathanglover for the PR and @jurasofish for the discussion.

Regarding the headers in the response, there was a previous PR (ultimately not merged) where this was [discussed](https://github.com/jordaneremieff/mangum/pull/133#issue-446617394) and the user reached the similar conclusion of this PR, so I think I will merge it but we can revisit later specifically if there are issues.

Patch:
diff --git a/mangum/handlers/aws_alb.py b/mangum/handlers/aws_alb.py
index 33e0968..6fef8b5 100644
--- a/mangum/handlers/aws_alb.py
+++ b/mangum/handlers/aws_alb.py
@@ -1,11 +1,30 @@
 import base64
 import urllib.parse
-from typing import Dict, Any
+from typing import Any, Dict, Generator, List, Tuple
 
 from .abstract_handler import AbstractHandler
 from .. import Response, Request
 
 
+def all_casings(input_string: str) -> Generator:
+    """
+    Permute all casings of a given string.
+    A pretty algoritm, via @Amber
+    http://stackoverflow.com/questions/6792803/finding-all-possible-case-permutations-in-python
+    """
+    if not input_string:
+        yield ""
+    else:
+        first = input_string[:1]
+        if first.lower() == first.upper():
+            for sub_casing in all_casings(input_string[1:]):
+                yield first + sub_casing
+        else:
+            for sub_casing in all_casings(input_string[1:]):
+                yield first.lower() + sub_casing
+                yield first.upper() + sub_casing
+
+
 class AwsAlb(AbstractHandler):
     """
     Handles AWS Elastic Load Balancer, really Application Load Balancer events
@@ -66,10 +85,27 @@ class AwsAlb(AbstractHandler):
 
         return body
 
-    def transform_response(self, response: Response) -> Dict[str, Any]:
+    def handle_headers(
+        self,
+        response_headers: List[List[bytes]],
+    ) -> Tuple[Dict[str, str], Dict[str, List[str]]]:
         headers, multi_value_headers = self._handle_multi_value_headers(
-            response.headers
+            response_headers
         )
+        if "multiValueHeaders" not in self.trigger_event:
+            # If there are multiple occurrences of headers, create case-mutated
+            # variations: https://github.com/logandk/serverless-wsgi/issues/11
+            for key, values in multi_value_headers.items():
+                if len(values) > 1:
+                    for value, cased_key in zip(values, all_casings(key)):
+                        headers[cased_key] = value
+
+            multi_value_headers = {}
+
+        return headers, multi_value_headers
+
+    def transform_response(self, response: Response) -> Dict[str, Any]:
+        headers, multi_value_headers = self.handle_headers(response.headers)
 
         body, is_base64_encoded = self._handle_base64_response_body(
             response.body, headers


Test patch:
diff --git a/tests/handlers/test_aws_alb.py b/tests/handlers/test_aws_alb.py
index d922c37..712b52b 100644
--- a/tests/handlers/test_aws_alb.py
+++ b/tests/handlers/test_aws_alb.py
@@ -5,9 +5,14 @@ from mangum.handlers import AwsAlb
 
 
 def get_mock_aws_alb_event(
-    method, path, multi_value_query_parameters, body, body_base64_encoded
+    method,
+    path,
+    multi_value_query_parameters,
+    body,
+    body_base64_encoded,
+    multi_value_headers=True,
 ):
-    return {
+    event = {
         "requestContext": {
             "elb": {
                 "targetGroupArn": "arn:aws:elasticloadbalancing:us-east-2:123456789012:targetgroup/lambda-279XGJDqGZ5rsrHC2Fjr/49e9d65c45c6791a"  # noqa: E501
@@ -38,6 +43,10 @@ def get_mock_aws_alb_event(
         "body": body,
         "isBase64Encoded": body_base64_encoded,
     }
+    if multi_value_headers:
+        event["multiValueHeaders"] = {}
+
+    return event
 
 
 def test_aws_alb_basic():
@@ -226,7 +235,7 @@ def test_aws_alb_scope_real(
         assert handler.body == b""
 
 
-def test_aws_alb_set_cookies() -> None:
+def test_aws_alb_set_cookies_multiValueHeaders() -> None:
     async def app(scope, receive, send):
         await send(
             {
@@ -255,6 +264,39 @@ def test_aws_alb_set_cookies() -> None:
     }
 
 
+def test_aws_alb_set_cookies_headers() -> None:
+    async def app(scope, receive, send):
+        await send(
+            {
+                "type": "http.response.start",
+                "status": 200,
+                "headers": [
+                    [b"content-type", b"text/plain; charset=utf-8"],
+                    [b"set-cookie", b"cookie1=cookie1; Secure"],
+                    [b"set-cookie", b"cookie2=cookie2; Secure"],
+                ],
+            }
+        )
+        await send({"type": "http.response.body", "body": b"Hello, world!"})
+
+    handler = Mangum(app, lifespan="off")
+    event = get_mock_aws_alb_event(
+        "GET", "/test", {}, None, False, multi_value_headers=False
+    )
+    response = handler(event, {})
+    assert response == {
+        "statusCode": 200,
+        "isBase64Encoded": False,
+        "headers": {
+            "content-type": "text/plain; charset=utf-8",
+            "set-cookie": "cookie1=cookie1; Secure",
+            "Set-cookie": "cookie2=cookie2; Secure",
+        },
+        "multiValueHeaders": {},
+        "body": "Hello, world!",
+    }
+
+
 @pytest.mark.parametrize(
     "method,content_type,raw_res_body,res_body,res_base64_encoded",
     [