Repository: getmoto/moto
Pull request #8172 (2024-09-28 17:25:57)

Scheduler: start_date parameter validation (create_schedule)
There is a missing validation on the `start_date` parameter in `create_schedule` action if the `schedule_expression` is recurrence and `start_date` is today's date, so there might be a `ValidationException` with the message: _**The StartDate you specify cannot be earlier than 5 minutes ago.**_



Patch:
diff --git a/moto/ec2/models/network_acls.py b/moto/ec2/models/network_acls.py
index aa11829e6..d9570cc53 100644
--- a/moto/ec2/models/network_acls.py
+++ b/moto/ec2/models/network_acls.py
@@ -59,6 +59,7 @@ class NetworkAclBackend:
                 icmp_type=None,
                 port_range_from=None,
                 port_range_to=None,
+                ipv6_cidr_block=None,
             )
 
     def delete_network_acl(self, network_acl_id: str) -> "NetworkAcl":
@@ -87,6 +88,7 @@ class NetworkAclBackend:
         icmp_type: Optional[int],
         port_range_from: Optional[int],
         port_range_to: Optional[int],
+        ipv6_cidr_block: Optional[str],
     ) -> "NetworkAclEntry":
         network_acl = self.get_network_acl(network_acl_id)
         if any(
@@ -96,16 +98,17 @@ class NetworkAclBackend:
             raise NetworkAclEntryAlreadyExistsError(rule_number)
         network_acl_entry = NetworkAclEntry(
             self,
-            network_acl_id,
-            rule_number,
-            protocol,
-            rule_action,
-            egress,
-            cidr_block,
-            icmp_code,
-            icmp_type,
-            port_range_from,
-            port_range_to,
+            network_acl_id=network_acl_id,
+            rule_number=rule_number,
+            protocol=protocol,
+            rule_action=rule_action,
+            egress=egress,
+            cidr_block=cidr_block,
+            icmp_code=icmp_code,
+            icmp_type=icmp_type,
+            port_range_from=port_range_from,
+            port_range_to=port_range_to,
+            ipv6_cidr_block=ipv6_cidr_block,
         )
 
         network_acl.network_acl_entries.append(network_acl_entry)
@@ -136,19 +139,21 @@ class NetworkAclBackend:
         icmp_type: int,
         port_range_from: int,
         port_range_to: int,
+        ipv6_cidr_block: Optional[str],
     ) -> "NetworkAclEntry":
         self.delete_network_acl_entry(network_acl_id, rule_number, egress)
         network_acl_entry = self.create_network_acl_entry(
-            network_acl_id,
-            rule_number,
-            protocol,
-            rule_action,
-            egress,
-            cidr_block,
-            icmp_code,
-            icmp_type,
-            port_range_from,
-            port_range_to,
+            network_acl_id=network_acl_id,
+            rule_number=rule_number,
+            protocol=protocol,
+            rule_action=rule_action,
+            egress=egress,
+            cidr_block=cidr_block,
+            icmp_code=icmp_code,
+            icmp_type=icmp_type,
+            port_range_from=port_range_from,
+            port_range_to=port_range_to,
+            ipv6_cidr_block=ipv6_cidr_block,
         )
         return network_acl_entry
 
@@ -285,6 +290,7 @@ class NetworkAclEntry(TaggedEC2Resource):
         icmp_type: Optional[int],
         port_range_from: Optional[int],
         port_range_to: Optional[int],
+        ipv6_cidr_block: Optional[str],
     ):
         self.ec2_backend = ec2_backend
         self.network_acl_id = network_acl_id
@@ -293,6 +299,7 @@ class NetworkAclEntry(TaggedEC2Resource):
         self.rule_action = rule_action
         self.egress = egress
         self.cidr_block = cidr_block
+        self.ipv6_cidr_block = ipv6_cidr_block
         self.icmp_code = icmp_code
         self.icmp_type = icmp_type
         self.port_range_from = port_range_from
diff --git a/moto/ec2/responses/network_acls.py b/moto/ec2/responses/network_acls.py
index b99b87c35..ed6da1130 100644
--- a/moto/ec2/responses/network_acls.py
+++ b/moto/ec2/responses/network_acls.py
@@ -22,18 +22,20 @@ class NetworkACLs(EC2BaseResponse):
         icmp_type = self._get_param("Icmp.Type")
         port_range_from = self._get_param("PortRange.From")
         port_range_to = self._get_param("PortRange.To")
+        ipv6_cidr_block = self._get_param("Ipv6CidrBlock")
 
         network_acl_entry = self.ec2_backend.create_network_acl_entry(
-            network_acl_id,
-            rule_number,
-            protocol,
-            rule_action,
-            egress,
-            cidr_block,
-            icmp_code,
-            icmp_type,
-            port_range_from,
-            port_range_to,
+            network_acl_id=network_acl_id,
+            rule_number=rule_number,
+            protocol=protocol,
+            rule_action=rule_action,
+            egress=egress,
+            cidr_block=cidr_block,
+            icmp_code=icmp_code,
+            icmp_type=icmp_type,
+            port_range_from=port_range_from,
+            port_range_to=port_range_to,
+            ipv6_cidr_block=ipv6_cidr_block,
         )
 
         template = self.response_template(CREATE_NETWORK_ACL_ENTRY_RESPONSE)
@@ -64,18 +66,20 @@ class NetworkACLs(EC2BaseResponse):
         icmp_type = self._get_param("Icmp.Type")
         port_range_from = self._get_param("PortRange.From")
         port_range_to = self._get_param("PortRange.To")
+        ipv6_cidr_block = self._get_param("Ipv6CidrBlock")
 
         self.ec2_backend.replace_network_acl_entry(
-            network_acl_id,
-            rule_number,
-            protocol,
-            rule_action,
-            egress,
-            cidr_block,
-            icmp_code,
-            icmp_type,
-            port_range_from,
-            port_range_to,
+            network_acl_id=network_acl_id,
+            rule_number=rule_number,
+            protocol=protocol,
+            rule_action=rule_action,
+            egress=egress,
+            cidr_block=cidr_block,
+            icmp_code=icmp_code,
+            icmp_type=icmp_type,
+            port_range_from=port_range_from,
+            port_range_to=port_range_to,
+            ipv6_cidr_block=ipv6_cidr_block,
         )
 
         template = self.response_template(REPLACE_NETWORK_ACL_ENTRY_RESPONSE)
@@ -139,7 +143,12 @@ DESCRIBE_NETWORK_ACL_RESPONSE = """
            <protocol>{{ entry.protocol }}</protocol>
            <ruleAction>{{ entry.rule_action }}</ruleAction>
            <egress>{{ entry.egress.lower() }}</egress>
-           <cidrBlock>{{ entry.cidr_block }}</cidrBlock>
+           {% if entry.cidr_block %}<cidrBlock>{{ entry.cidr_block }}</cidrBlock>{% endif %}
+           {% if entry.ipv6_cidr_block %}<ipv6CidrBlock>{{ entry.ipv6_cidr_block }}</ipv6CidrBlock>{% endif %}
+           {% if entry.icmp_code or entry.icmp_type %}<icmpTypeCode>
+             {% if entry.icmp_code %}<code>{{ entry.icmp_code }}</code>{% endif %}
+             {% if entry.icmp_type %}<type>{{ entry.icmp_type }}</type>{% endif %}
+           </icmpTypeCode>{% endif %}
            {% if entry.port_range_from or entry.port_range_to %}
              <portRange>
                <from>{{ entry.port_range_from }}</from>
diff --git a/moto/scheduler/exceptions.py b/moto/scheduler/exceptions.py
index 05f999294..91d6a5a91 100644
--- a/moto/scheduler/exceptions.py
+++ b/moto/scheduler/exceptions.py
@@ -18,3 +18,10 @@ class ScheduleNotFound(JsonRESTError):
 class ScheduleGroupNotFound(JsonRESTError):
     def __init__(self) -> None:
         super().__init__("ResourceNotFoundException", "ScheduleGroup not found")
+
+
+class ValidationException(JsonRESTError):
+    code = 400
+
+    def __init__(self, message: str):
+        super().__init__(error_type="ValidationException", message=message)
diff --git a/moto/scheduler/models.py b/moto/scheduler/models.py
index 124723b7b..7a2bcc157 100644
--- a/moto/scheduler/models.py
+++ b/moto/scheduler/models.py
@@ -1,14 +1,20 @@
 """EventBridgeSchedulerBackend class with methods for supported APIs."""
 
-from typing import Any, Dict, Iterable, List, Optional
+import datetime
+from typing import Any, Dict, Iterable, List, Optional, cast
 
 from moto.core.base_backend import BackendDict, BaseBackend
 from moto.core.common_models import BaseModel
-from moto.core.utils import unix_time
+from moto.core.utils import unix_time, utcfromtimestamp, utcnow
 from moto.utilities.tagging_service import TaggingService
 from moto.utilities.utils import get_partition
 
-from .exceptions import ScheduleExists, ScheduleGroupNotFound, ScheduleNotFound
+from .exceptions import (
+    ScheduleExists,
+    ScheduleGroupNotFound,
+    ScheduleNotFound,
+    ValidationException,
+)
 
 
 class Schedule(BaseModel):
@@ -39,7 +45,7 @@ class Schedule(BaseModel):
         self.target = Schedule.validate_target(target)
         self.state = state or "ENABLED"
         self.kms_key_arn = kms_key_arn
-        self.start_date = start_date
+        self.start_date = self._validate_start_date(start_date)
         self.end_date = end_date
         self.action_after_completion = action_after_completion
         self.creation_date = self.last_modified_date = unix_time()
@@ -53,6 +59,22 @@ class Schedule(BaseModel):
             }
         return target
 
+    def _validate_start_date(self, start_date: Optional[str]) -> Optional[str]:
+        # `.count("*")` means a recurrence expression
+        # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/scheduler/client/create_schedule.html (StartDate parameter)
+        if self.schedule_expression.count("*") and start_date is not None:
+            start_date_as_dt = utcfromtimestamp(cast(int, start_date))
+            now = utcnow()
+            if start_date_as_dt.date() == now.date():
+                diff = abs(now - start_date_as_dt)
+                rule = datetime.timedelta(minutes=5)
+                within_rule = diff <= rule
+                if not within_rule:
+                    raise ValidationException(
+                        message="The StartDate you specify cannot be earlier than 5 minutes ago."
+                    )
+        return start_date
+
     def to_dict(self, short: bool = False) -> Dict[str, Any]:
         dct: Dict[str, Any] = {
             "Arn": self.arn,


Test patch:
diff --git a/.github/workflows/tests_terraform_examples.yml b/.github/workflows/tests_terraform_examples.yml
index 672afce50..d83ef22cc 100644
--- a/.github/workflows/tests_terraform_examples.yml
+++ b/.github/workflows/tests_terraform_examples.yml
@@ -6,12 +6,29 @@ name: Terraform Examples
 on: [workflow_call]
 
 jobs:
+  find-jobs:
+    runs-on: ubuntu-latest
+    name: Find Jobs
+    outputs:
+      folders: ${{ steps.set-folders.outputs.folders }}
+    steps:
+      - uses: actions/checkout@v4
+      - id: set-folders
+        name: Get Services
+        shell: bash
+        run: |
+          cd other_langs/terraform/
+          folders=$(tree -J -d -L 1 | jq -c '.[0].contents | map(.name)')
+          echo $folders
+          echo "folders=$folders" >> $GITHUB_OUTPUT
+
   test:
     runs-on: ubuntu-latest
+    needs: find-jobs
     strategy:
       fail-fast: false
       matrix:
-        service: ["acm", "autoscaling", "awslambda", "cloudfront", "codebuild", "ds", "elb", "iam", "rds", "route53", "sqs", "wafv2"]
+        service: ${{ fromJson(needs.find-jobs.outputs.folders )}}
 
     steps:
     - uses: actions/checkout@v4
@@ -39,31 +56,3 @@ jobs:
         sleep 30
         terraform plan -detailed-exitcode
         terraform apply -destroy --auto-approve
-
-  test_create_only:
-    runs-on: ubuntu-latest
-    strategy:
-      fail-fast: false
-      matrix:
-        service: ["ec2"]
-
-    steps:
-    - uses: actions/checkout@v4
-      with:
-        fetch-depth: 0
-    - name: Set up Python 3.8
-      uses: actions/setup-python@v5
-      with:
-        python-version: "3.8"
-    - name: Start MotoServer
-      run: |
-        pip install build
-        python -m build
-        docker run --rm -t --name motoserver -e TEST_SERVER_MODE=true -e AWS_SECRET_ACCESS_KEY=server_secret -e AWS_ACCESS_KEY_ID=server_key -v `pwd`:/moto -p 5000:5000 -v /var/run/docker.sock:/var/run/docker.sock python:3.10-slim /moto/scripts/ci_moto_server.sh &
-        python scripts/ci_wait_for_server.py
-    - name: Run tests
-      run: |
-        mkdir ~/.aws && touch ~/.aws/credentials && echo -e "[default]\naws_access_key_id = test\naws_secret_access_key = test" > ~/.aws/credentials
-        cd other_langs/terraform/${{ matrix.service }}
-        terraform init
-        terraform apply --auto-approve
diff --git a/tests/test_ec2/test_network_acls.py b/tests/test_ec2/test_network_acls.py
index bde8c726c..3fa630763 100644
--- a/tests/test_ec2/test_network_acls.py
+++ b/tests/test_ec2/test_network_acls.py
@@ -69,6 +69,7 @@ def test_network_acl_entries():
         Protocol="6",  # TCP
         RuleAction="ALLOW",
         CidrBlock="0.0.0.0/0",
+        Ipv6CidrBlock="asdf",
         Egress=False,
         PortRange={"From": 443, "To": 443},
     )
@@ -88,6 +89,7 @@ def test_network_acl_entries():
     assert entries[0]["Egress"] is False
     assert entries[0]["PortRange"] == {"To": 443, "From": 443}
     assert entries[0]["CidrBlock"] == "0.0.0.0/0"
+    assert entries[0]["Ipv6CidrBlock"] == "asdf"
 
 
 @mock_aws
diff --git a/tests/test_scheduler/test_scheduler.py b/tests/test_scheduler/test_scheduler.py
index 6d4512241..6bb672f26 100644
--- a/tests/test_scheduler/test_scheduler.py
+++ b/tests/test_scheduler/test_scheduler.py
@@ -1,4 +1,4 @@
-from datetime import datetime
+from datetime import datetime, timedelta, timezone
 
 import boto3
 import pytest
@@ -6,6 +6,7 @@ from botocore.client import ClientError
 
 from moto import mock_aws
 from moto.core import DEFAULT_ACCOUNT_ID
+from moto.core.utils import utcnow
 
 # See our Development Tips on writing tests for hints on how to write good tests:
 # http://docs.getmoto.org/en/latest/docs/contributing/development_tips/tests.html
@@ -229,3 +230,64 @@ def test_delete_schedule_for_none_existing_schedule():
     err = exc.value.response["Error"]
     assert err["Code"] == "ResourceNotFoundException"
     assert err["Message"] == "Schedule my-schedule does not exist."
+
+
+@mock_aws
+def test_create_get_schedule__with_start_date():
+    # Arrange
+    expected_start_date = start_date = utcnow().replace(microsecond=0)
+
+    # Act
+    client = boto3.client("scheduler", region_name="eu-west-1")
+    client.create_schedule(
+        StartDate=start_date,
+        Name="my-schedule",
+        ScheduleExpression="some cron *",
+        FlexibleTimeWindow={
+            "MaximumWindowInMinutes": 4,
+            "Mode": "OFF",
+        },
+        Target={
+            "Arn": "not supported yet",
+            "RoleArn": "n/a",
+        },
+    )
+
+    # Assert
+    resp = client.get_schedule(Name="my-schedule")
+    start_date_as_utc = datetime.astimezone(resp["StartDate"], timezone.utc).replace(
+        tzinfo=None
+    )
+    assert start_date_as_utc == expected_start_date
+
+
+@mock_aws
+def test_create_schedule__exception_with_start_date():
+    # Arrange
+    expected_error = "ValidationException"
+    expected_error_message = (
+        "The StartDate you specify cannot be earlier than 5 minutes ago."
+    )
+    start_date = utcnow() - timedelta(minutes=6)
+
+    # Act
+    with pytest.raises(ClientError) as exc:
+        client = boto3.client("scheduler", region_name="eu-west-1")
+        client.create_schedule(
+            Name="my-schedule",
+            StartDate=start_date,
+            ScheduleExpression="some cron *",
+            FlexibleTimeWindow={
+                "MaximumWindowInMinutes": 4,
+                "Mode": "OFF",
+            },
+            Target={
+                "Arn": "not supported yet",
+                "RoleArn": "n/a",
+            },
+        )
+
+    # Assert
+    err = exc.value.response["Error"]
+    assert err["Code"] == expected_error
+    assert err["Message"] == expected_error_message