Repository: getmoto/moto
Pull request #8563 (2025-02-04 15:57:21)

ELB: Port and protocol validations for create listener
When creating a listener, port and protocol should satisfy some conditions base on the load balancer type.

https://docs.aws.amazon.com/cli/latest/reference/elbv2/create-listener.html#options

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getmoto/moto/pull/8563?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) Report
Attention: Patch coverage is `90.00000%` with `3 lines` in your changes missing coverage. Please review.
> Project coverage is 92.65%. Comparing base [(`5abecce`)](https://app.codecov.io/gh/getmoto/moto/commit/5abecce75285f6be8f6e2db7d488cabd8eda49e2?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) to head [(`eb3c105`)](https://app.codecov.io/gh/getmoto/moto/commit/eb3c105add615f77b75e021b233d4c71b563c6ff?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).

| [Files with missing lines](https://app.codecov.io/gh/getmoto/moto/pull/8563?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Patch % | Lines |
|---|---|---|
| [moto/elbv2/exceptions.py](https://app.codecov.io/gh/getmoto/moto/pull/8563?src=pr&el=tree&filepath=moto%2Felbv2%2Fexceptions.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#diff-bW90by9lbGJ2Mi9leGNlcHRpb25zLnB5) | 77.77% | [2 Missing :warning: ](https://app.codecov.io/gh/getmoto/moto/pull/8563?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) |
| [moto/elbv2/models.py](https://app.codecov.io/gh/getmoto/moto/pull/8563?src=pr&el=tree&filepath=moto%2Felbv2%2Fmodels.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#diff-bW90by9lbGJ2Mi9tb2RlbHMucHk=) | 95.23% | [1 Missing :warning: ](https://app.codecov.io/gh/getmoto/moto/pull/8563?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #8563      +/-   ##
==========================================
- Coverage   92.65%   92.65%   -0.01%     
==========================================
  Files        1231     1231              
  Lines      106793   106822      +29     
==========================================
+ Hits        98948    98974      +26     
- Misses       7845     7848       +3     
```

| [Flag](https://app.codecov.io/gh/getmoto/moto/pull/8563/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Coverage Δ | |
|---|---|---|
| [servertests](https://app.codecov.io/gh/getmoto/moto/pull/8563/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `28.06% <20.00%> (-0.01%)` | :arrow_down: |
| [unittests](https://app.codecov.io/gh/getmoto/moto/pull/8563/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `92.62% <90.00%> (-0.01%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/getmoto/moto/pull/8563?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).


Patch:
diff --git a/moto/elbv2/exceptions.py b/moto/elbv2/exceptions.py
index 8ab6d7a77..b8783e03b 100644
--- a/moto/elbv2/exceptions.py
+++ b/moto/elbv2/exceptions.py
@@ -1,4 +1,4 @@
-from typing import Any, Optional
+from typing import Any, List, Optional
 
 from moto.core.exceptions import RESTError
 
@@ -209,3 +209,18 @@ class ValidationError(ELBClientError):
 class InvalidConfigurationRequest(ELBClientError):
     def __init__(self, msg: str):
         super().__init__("InvalidConfigurationRequest", msg)
+
+
+class InvalidProtocol(ELBClientError):
+    def __init__(self, protocol: str, valid_protocols: List[str]):
+        msg = f"Listener protocol '{protocol}' must be one of '{', '.join(valid_protocols)}'"
+        super().__init__("ValidationError", msg)
+
+
+class InvalidProtocolValue(ELBClientError):
+    def __init__(self, protocol: str, valid_protocols: List[str]):
+        msg = (
+            f"1 validation error detected: Value '{protocol}' at 'protocol' failed to satisfy constraint: "
+            f"Member must satisfy enum value set: [{', '.join(valid_protocols)}]"
+        )
+        super().__init__("ValidationError", msg)
diff --git a/moto/elbv2/models.py b/moto/elbv2/models.py
index 10ae12cd9..c548e9cb2 100644
--- a/moto/elbv2/models.py
+++ b/moto/elbv2/models.py
@@ -29,6 +29,8 @@ from .exceptions import (
     InvalidDescribeRulesRequest,
     InvalidLoadBalancerActionException,
     InvalidModifyRuleArgumentsError,
+    InvalidProtocol,
+    InvalidProtocolValue,
     InvalidStatusCodeActionTypeError,
     InvalidTargetError,
     InvalidTargetGroupNameError,
@@ -1077,6 +1079,42 @@ class ELBv2Backend(BaseBackend):
             else:
                 raise InvalidActionTypeError(action_type, index)
 
+    def _validate_port_and_protocol(
+        self, loadbalancer_type: str, port: Optional[str], protocol: Optional[str]
+    ) -> None:
+        if loadbalancer_type in {"application", "network"}:
+            if port is None:
+                raise ValidationError("A listener port must be specified")
+            if protocol is None:
+                raise ValidationError("A listener protocol must be specified")
+
+            valid_application_protocols = ["HTTP", "HTTPS"]
+            valid_network_protocols = ["UDP", "TCP", "TLS", "TCP_UDP"]
+            all_valid_protocols = (
+                valid_application_protocols + valid_network_protocols + ["GENEVE"]
+            )
+
+            if protocol not in all_valid_protocols:
+                raise InvalidProtocolValue(
+                    protocol, valid_application_protocols + valid_network_protocols
+                )
+
+            if loadbalancer_type == "application":
+                if protocol not in valid_application_protocols:
+                    raise InvalidProtocol(protocol, valid_application_protocols)
+            else:
+                if protocol not in valid_network_protocols:
+                    raise InvalidProtocol(protocol, valid_network_protocols)
+        else:
+            if port is not None:
+                raise ValidationError(
+                    "A port cannot be specified for gateway listeners"
+                )
+            if protocol is not None:
+                raise ValidationError(
+                    "A protocol cannot be specified for gateway listeners"
+                )
+
     def _validate_fixed_response_action(
         self, action: FakeAction, i: int, index: int
     ) -> None:
@@ -1425,6 +1463,7 @@ Member must satisfy regular expression pattern: {expression}"
         if port in balancer.listeners:
             raise DuplicateListenerError()
 
+        self._validate_port_and_protocol(balancer.loadbalancer_type, port, protocol)
         self._validate_actions(default_actions)
 
         arn = (
diff --git a/other_langs/terraform/elb/load_balancer.tf b/other_langs/terraform/elb/load_balancer.tf
index 81199efa2..f348f1988 100644
--- a/other_langs/terraform/elb/load_balancer.tf
+++ b/other_langs/terraform/elb/load_balancer.tf
@@ -12,7 +12,7 @@ data "aws_subnets" "all" {
 resource "aws_lb" "this" {
   name                       = "nlb-test"
   internal                   = true
-  load_balancer_type         = "network"
+  load_balancer_type         = "application"
   subnets                    = data.aws_subnets.all.ids
   enable_deletion_protection = false
 }
\ No newline at end of file


Test patch:
diff --git a/tests/test_elbv2/test_elbv2.py b/tests/test_elbv2/test_elbv2.py
index 512f31b39..b47d6d79a 100644
--- a/tests/test_elbv2/test_elbv2.py
+++ b/tests/test_elbv2/test_elbv2.py
@@ -37,7 +37,7 @@ def test_create_load_balancer():
     assert tags == {"key_name": "a_value"}
 
 
-def create_load_balancer():
+def create_load_balancer(load_balancer_type: str = "application"):
     conn = boto3.client("elbv2", region_name="us-east-1")
     ec2 = boto3.resource("ec2", region_name="us-east-1")
 
@@ -58,6 +58,7 @@ def create_load_balancer():
         SecurityGroups=[security_group.id],
         Scheme="internal",
         Tags=[{"Key": "key_name", "Value": "a_value"}],
+        Type=load_balancer_type,
     )
     return response, vpc, security_group, subnet1, subnet2, conn
 
@@ -255,8 +256,9 @@ def test_create_elb_in_multiple_region():
 
 
 @mock_aws
-def test_create_listeners_without_port():
-    response, vpc, _, _, _, conn = create_load_balancer()
+@pytest.mark.parametrize("load_balancer_type", ["application", "network"])
+def test_create_listeners_without_port(load_balancer_type):
+    response, vpc, _, _, _, conn = create_load_balancer(load_balancer_type)
     load_balancer_arn = response["LoadBalancers"][0]["LoadBalancerArn"]
     response = conn.create_target_group(
         Name="a-target",
@@ -274,18 +276,161 @@ def test_create_listeners_without_port():
     )
     target_group = response["TargetGroups"][0]
     target_group_arn = target_group["TargetGroupArn"]
-    response = conn.create_listener(
-        LoadBalancerArn=load_balancer_arn,
-        Protocol="HTTP",
-        DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}],
-    )
+    with pytest.raises(ClientError) as exc:
+        conn.create_listener(
+            LoadBalancerArn=load_balancer_arn,
+            Protocol="HTTP",
+            DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}],
+        )
+    err = exc.value.response["Error"]
+    assert err["Code"] == "ValidationError"
+    assert err["Message"] == "A listener port must be specified"
 
-    listener = response["Listeners"][0]
-    assert listener.get("Port") is None
-    assert listener["Protocol"] == "HTTP"
-    assert listener["DefaultActions"] == [
-        {"TargetGroupArn": target_group_arn, "Type": "forward"}
-    ]
+
+@mock_aws
+@pytest.mark.parametrize("load_balancer_type", ["application", "network"])
+def test_create_listeners_without_protocol(load_balancer_type):
+    response, _, _, _, _, conn = create_load_balancer(load_balancer_type)
+    load_balancer_arn = response["LoadBalancers"][0]["LoadBalancerArn"]
+    with pytest.raises(ClientError) as exc:
+        conn.create_listener(
+            LoadBalancerArn=load_balancer_arn,
+            Port=8080,
+            DefaultActions=[
+                {
+                    "Type": "fixed-response",
+                    "FixedResponseConfig": {
+                        "MessageBody": "Hello, World!",
+                        "StatusCode": "200",
+                        "ContentType": "text/plain",
+                    },
+                }
+            ],
+        )
+    err = exc.value.response["Error"]
+    assert err["Code"] == "ValidationError"
+    assert err["Message"] == "A listener protocol must be specified"
+
+
+@mock_aws
+@pytest.mark.parametrize(
+    ["load_balancer_type", "protocol", "exception_message"],
+    [
+        (
+            "application",
+            "GENEVE",
+            "Listener protocol 'GENEVE' must be one of 'HTTP, HTTPS'",
+        ),
+        (
+            "network",
+            "GENEVE",
+            "Listener protocol 'GENEVE' must be one of 'UDP, TCP, TLS, TCP_UDP'",
+        ),
+        (
+            "application",
+            "TCP",
+            "Listener protocol 'TCP' must be one of 'HTTP, HTTPS'",
+        ),
+        (
+            "application",
+            "UDP",
+            "Listener protocol 'UDP' must be one of 'HTTP, HTTPS'",
+        ),
+        (
+            "application",
+            "TCP_UDP",
+            "Listener protocol 'TCP_UDP' must be one of 'HTTP, HTTPS'",
+        ),
+        (
+            "application",
+            "TLS",
+            "Listener protocol 'TLS' must be one of 'HTTP, HTTPS'",
+        ),
+        (
+            "network",
+            "HTTP",
+            "Listener protocol 'HTTP' must be one of 'UDP, TCP, TLS, TCP_UDP'",
+        ),
+        (
+            "network",
+            "HTTPS",
+            "Listener protocol 'HTTPS' must be one of 'UDP, TCP, TLS, TCP_UDP'",
+        ),
+        (
+            "application",
+            "INVALID",
+            "1 validation error detected: Value 'INVALID' at 'protocol' failed to satisfy constraint: "
+            "Member must satisfy enum value set: [HTTP, HTTPS, UDP, TCP, TLS, TCP_UDP]",
+        ),
+        (
+            "network",
+            "INVALID",
+            "1 validation error detected: Value 'INVALID' at 'protocol' failed to satisfy constraint: "
+            "Member must satisfy enum value set: [HTTP, HTTPS, UDP, TCP, TLS, TCP_UDP]",
+        ),
+    ],
+)
+def test_create_listeners_with_invalid_protocol(
+    load_balancer_type, protocol, exception_message
+):
+    response, _, _, _, _, conn = create_load_balancer(load_balancer_type)
+    load_balancer_arn = response["LoadBalancers"][0]["LoadBalancerArn"]
+    with pytest.raises(ClientError) as exc:
+        conn.create_listener(
+            LoadBalancerArn=load_balancer_arn,
+            Port=1234,
+            Protocol=protocol,
+            DefaultActions=[
+                {
+                    "Type": "fixed-response",
+                    "FixedResponseConfig": {
+                        "MessageBody": "Hello, World!",
+                        "StatusCode": "200",
+                        "ContentType": "text/plain",
+                    },
+                }
+            ],
+        )
+    err = exc.value.response["Error"]
+    assert err["Code"] == "ValidationError"
+    assert err["Message"] == exception_message
+
+
+@mock_aws
+@pytest.mark.parametrize(
+    ["param", "value", "exception_message"],
+    [
+        ("Port", 80, "A port cannot be specified for gateway listeners"),
+        ("Protocol", "TCP", "A protocol cannot be specified for gateway listeners"),
+    ],
+)
+def test_create_gateway_listener_with_invalid_params(param, value, exception_message):
+    response, vpc, _, _, _, conn = create_load_balancer("gateway")
+    load_balancer_arn = response["LoadBalancers"][0]["LoadBalancerArn"]
+    response = conn.create_target_group(
+        Name="a-target",
+        Protocol="TCP",
+        Port=80,
+        VpcId=vpc.id,
+        HealthCheckProtocol="TCP",
+        HealthCheckPort="traffic-port",
+        HealthCheckPath="/",
+        HealthCheckIntervalSeconds=5,
+        HealthCheckTimeoutSeconds=3,
+        HealthyThresholdCount=5,
+        UnhealthyThresholdCount=2,
+    )
+    target_group = response["TargetGroups"][0]
+    target_group_arn = target_group["TargetGroupArn"]
+    with pytest.raises(ClientError) as exc:
+        conn.create_listener(
+            LoadBalancerArn=load_balancer_arn,
+            DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}],
+            **{param: value},
+        )
+    err = exc.value.response["Error"]
+    assert err["Code"] == "ValidationError"
+    assert err["Message"] == exception_message
 
 
 @mock_aws