Repository: getmoto/moto
Pull request #7831 (2024-07-08 11:16:55)

EC2: Fix security group rule enumeration
It was reported that [`DescribeSecurityGroupRules`](https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DescribeSecurityGroupRules.html) does not return the `Description` field, see https://github.com/localstack/localstack/issues/8985.

Furthermore it was observed that the response is incorrectly constructed if the rules were adding to the security group using the [`IpPermission`](https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_IpPermission.html) objects:

```text
$ security_group_id=$(awslocal ec2 create-security-group --group-name example --description "Example security group" | jq -r .GroupId)

$ awslocal ec2 authorize-security-group-ingress --group-id "${security_group_id}" \
  --ip-permissions 'IpProtocol=tcp,FromPort=1111,ToPort=1111,IpRanges=[{CidrIp=127.0.0.1/32,Description=first-range},{CidrIp=127.0.0.2/32,Description=second-range}]'

$ awslocal ec2 describe-security-group-rules --filters "Name=group-id,Values=${security_group_id}"
{
    "SecurityGroupRules": [
        {
            "SecurityGroupRuleId": "sgr-bffc7c346b7639f8c",
            "GroupId": "sg-ab18766880b58bc1a",
            "GroupOwnerId": "000000000000",
            "IsEgress": false,
            "IpProtocol": "tcp",
            "FromPort": 1111,
            "ToPort": 1111,
            "CidrIpv4": "127.0.0.1/32",
            "Tags": []
        },
        {
            "SecurityGroupRuleId": "sgr-870c183b5a2176fc8",
            "GroupId": "sg-ab18766880b58bc1a",
            "GroupOwnerId": "000000000000",
            "IsEgress": true,
            "IpProtocol": "-1",
            "CidrIpv4": "0.0.0.0/0",
            "Tags": []
        }
    ]
}
```
The correct response must include one rule per IP range as follows (verified against AWS):
```text
{
    "SecurityGroupRules": [
        {
            "SecurityGroupRuleId": "sgr-0d678122d6b696215",
            "GroupId": "sg-0dd0742319446e450",
            "GroupOwnerId": "623948600419",
            "IsEgress": false,
            "IpProtocol": "tcp",
            "FromPort": 1111,
            "ToPort": 1111,
            "CidrIpv4": "127.0.0.2/32",
            "Description": "second-range",
            "Tags": []
        },
        {
            "SecurityGroupRuleId": "sgr-061f28ce42dea9209",
            "GroupId": "sg-0dd0742319446e450",
            "GroupOwnerId": "623948600419",
            "IsEgress": true,
            "IpProtocol": "-1",
            "FromPort": -1,
            "ToPort": -1,
            "CidrIpv4": "0.0.0.0/0",
            "Tags": []
        },
        {
            "SecurityGroupRuleId": "sgr-0baed3b39a3929eb2",
            "GroupId": "sg-0dd0742319446e450",
            "GroupOwnerId": "623948600419",
            "IsEgress": false,
            "IpProtocol": "tcp",
            "FromPort": 1111,
            "ToPort": 1111,
            "CidrIpv4": "127.0.0.1/32",
            "Description": "first-range",
            "Tags": []
        }
    ]
}
```

This PR fixes the `DescribeSecurityGroupRules` operation:
- Adds the `description` field
- Correctly enumerates and constructs the list of rules
- Makes the `FromPort` and `ToPort` fields to return -1 if not set

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getmoto/moto/pull/7831?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 94.33%. Comparing base [(`5934567`)](https://app.codecov.io/gh/getmoto/moto/commit/5934567d9d448857b3784a459ea43063c9d396b8?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) to head [(`c9d9c50`)](https://app.codecov.io/gh/getmoto/moto/commit/c9d9c50d77ca2defc25101bc016a31942671e1f1?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).
> Report is 2 commits behind head on master.

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


```diff
@@           Coverage Diff           @@
##           master    #7831   +/-   ##
=======================================
  Coverage   94.33%   94.33%           
=======================================
  Files        1107     1107           
  Lines       94237    94237           
=======================================
+ Hits        88900    88901    +1     
+ Misses       5337     5336    -1     
```

| [Flag](https://app.codecov.io/gh/getmoto/moto/pull/7831/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/7831/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `29.29% <ø> (ø)` | |
| [unittests](https://app.codecov.io/gh/getmoto/moto/pull/7831/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `94.30% <ø> (+<0.01%)` | :arrow_up: |

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/7831?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/docs/docs/services/athena.rst b/docs/docs/services/athena.rst
index 12145c85a..c90ccaf82 100644
--- a/docs/docs/services/athena.rst
+++ b/docs/docs/services/athena.rst
@@ -88,6 +88,8 @@ athena
 
         .. note:: The exact QueryExecutionId is not relevant here, but will likely be whatever value is returned by start_query_execution
 
+        Query results will also be stored in the S3 output location (in CSV format).
+
         
 
 - [ ] get_query_runtime_statistics
diff --git a/moto/athena/models.py b/moto/athena/models.py
index 93a563ae2..1ca9d35bd 100644
--- a/moto/athena/models.py
+++ b/moto/athena/models.py
@@ -5,6 +5,8 @@ from typing import Any, Dict, List, Optional
 from moto.core.base_backend import BackendDict, BaseBackend
 from moto.core.common_models import BaseModel
 from moto.moto_api._internal import mock_random
+from moto.s3.models import s3_backends
+from moto.s3.utils import bucket_and_name_from_url
 from moto.utilities.paginator import paginate
 from moto.utilities.utils import get_partition
 
@@ -232,8 +234,17 @@ class AthenaBackend(BaseBackend):
             execution_parameters=execution_parameters,
         )
         self.executions[execution.id] = execution
+
+        self._store_predefined_query_results(execution.id)
+
         return execution.id
 
+    def _store_predefined_query_results(self, exec_id: str) -> None:
+        if exec_id not in self.query_results and self.query_results_queue:
+            self.query_results[exec_id] = self.query_results_queue.pop(0)
+
+            self._store_query_result_in_s3(exec_id)
+
     def get_query_execution(self, exec_id: str) -> Execution:
         return self.executions[exec_id]
 
@@ -285,9 +296,11 @@ class AthenaBackend(BaseBackend):
 
         .. note:: The exact QueryExecutionId is not relevant here, but will likely be whatever value is returned by start_query_execution
 
+        Query results will also be stored in the S3 output location (in CSV format).
+
         """
-        if exec_id not in self.query_results and self.query_results_queue:
-            self.query_results[exec_id] = self.query_results_queue.pop(0)
+        self._store_predefined_query_results(exec_id)
+
         results = (
             self.query_results[exec_id]
             if exec_id in self.query_results
@@ -295,6 +308,31 @@ class AthenaBackend(BaseBackend):
         )
         return results
 
+    def _store_query_result_in_s3(self, exec_id: str) -> None:
+        try:
+            output_location = self.executions[exec_id].config["OutputLocation"]
+            bucket, key = bucket_and_name_from_url(output_location)
+
+            query_result = ""
+            for row in self.query_results[exec_id].rows:
+                query_result += ",".join(
+                    [
+                        f"\"{r['VarCharValue']}\"" if "VarCharValue" in r else ""
+                        for r in row["Data"]
+                    ]
+                )
+                query_result += "\n"
+
+            s3_backends[self.account_id][self.partition].put_object(
+                bucket_name=bucket,  # type: ignore
+                key_name=key,  # type: ignore
+                value=query_result.encode("utf-8"),
+            )
+        except:  # noqa
+            # Execution may not exist
+            # OutputLocation may not exist
+            pass
+
     def stop_query_execution(self, exec_id: str) -> None:
         execution = self.executions[exec_id]
         execution.status = "CANCELLED"
diff --git a/moto/ec2/models/instances.py b/moto/ec2/models/instances.py
index 615be6807..91efe920a 100644
--- a/moto/ec2/models/instances.py
+++ b/moto/ec2/models/instances.py
@@ -935,5 +935,5 @@ class InstanceBackend:
             )[0]
         )
         version = launch_template_arg.get("Version", template.latest_version_number)
-        template_version = template.get_version(int(version))
+        template_version = template.get_version(version)
         return template_version
diff --git a/moto/ec2/responses/security_groups.py b/moto/ec2/responses/security_groups.py
index 49553d05e..cf1db0b2b 100644
--- a/moto/ec2/responses/security_groups.py
+++ b/moto/ec2/responses/security_groups.py
@@ -276,19 +276,15 @@ DESCRIBE_SECURITY_GROUP_RULES_RESPONSE = """
   <requestId>{{ request_id }}</requestId>
   <securityGroupRuleSet>
         {% for rule in rules %}
+          {% for ip_range in rule.ip_ranges %}
             <item>
-                {% if rule.from_port is not none %}
-                <fromPort>{{ rule.from_port }}</fromPort>
-                {% endif %}
-                {% if rule.to_port is not none %}
-                  <toPort>{{ rule.to_port }}</toPort>
-                {% endif %}
-                {% if rule.ip_ranges %}
-                  <cidrIpv4>{{ rule.ip_ranges[0]['CidrIp'] }}</cidrIpv4>
-                {% endif %}
+                <fromPort>{{ rule.from_port if rule.from_port is not none else -1 }}</fromPort>
+                <toPort>{{ rule.to_port if rule.to_port is not none else -1 }}</toPort>
+                <cidrIpv4>{{ ip_range['CidrIp'] }}</cidrIpv4>
                 <ipProtocol>{{ rule.ip_protocol }}</ipProtocol>
                 <groupId>{{ rule.group_id }}</groupId>
                 <groupOwnerId>{{ rule.owner_id }}</groupOwnerId>
+                <description>{{ ip_range['Description'] }}</description>
                 <isEgress>{{ 'true' if rule.is_egress else 'false' }}</isEgress>
                 <securityGroupRuleId>{{ rule.id }}</securityGroupRuleId>
                 <tagSet>
@@ -300,6 +296,7 @@ DESCRIBE_SECURITY_GROUP_RULES_RESPONSE = """
                 {% endfor %}
                 </tagSet> 
             </item>
+          {% endfor %}
         {% endfor %}
   </securityGroupRuleSet>
 </DescribeSecurityGroupRulesResponse>"""
diff --git a/moto/moto_proxy/certificate_creator.py b/moto/moto_proxy/certificate_creator.py
index c5699a0b4..db0c29dbe 100644
--- a/moto/moto_proxy/certificate_creator.py
+++ b/moto/moto_proxy/certificate_creator.py
@@ -64,10 +64,13 @@ class CertificateCreator:
             # All requests that match this wildcard certificate will reuse it
 
             wildcard_name = f"*.{'.'.join(full_name.split('.')[1:])}"
-            server_csr = f"{self.certdir.rstrip('/')}/{wildcard_name}.csr"
+            # In Linux we could use asterisk as part of the filename, but not on Windows
+            # So we need to pick a different (unique) filename that works everywhere
+            wildcard_filename = f"_.{'.'.join(full_name.split('.')[1:])}"
+            server_csr = f"{self.certdir.rstrip('/')}/{wildcard_filename}.csr"
 
             # Verify if the certificate already exists
-            certpath = f"{self.certdir.rstrip('/')}/{wildcard_name}.crt"
+            certpath = f"{self.certdir.rstrip('/')}/{wildcard_filename}.crt"
             if not os.path.isfile(certpath):
                 # Create a Config-file that contains the wildcard-name
                 with open(f"{self.certdir.rstrip('/')}/req.conf.tmpl", "r") as f:
@@ -77,12 +80,12 @@ class CertificateCreator:
                     "{{wildcard_name}}", wildcard_name
                 )
                 config_template_name = (
-                    f"{self.certdir.rstrip('/')}/{wildcard_name}.conf"
+                    f"{self.certdir.rstrip('/')}/{wildcard_filename}.conf"
                 )
                 with open(config_template_name, "w") as f:
                     f.write(config_template)
 
-                # Create an Certificate Signing Request
+                # Create a Certificate Signing Request
                 #
                 subject = f"/CN={full_name}"[0:64]
                 commands = [
diff --git a/moto/sesv2/responses.py b/moto/sesv2/responses.py
index 124f7db71..c8c09a663 100644
--- a/moto/sesv2/responses.py
+++ b/moto/sesv2/responses.py
@@ -7,7 +7,6 @@ from urllib.parse import unquote
 
 from moto.core.responses import BaseResponse
 
-from ..ses.responses import SEND_EMAIL_RESPONSE
 from .models import SESV2Backend, sesv2_backends
 
 
@@ -57,9 +56,7 @@ class SESV2Response(BaseResponse):
         elif "Template" in content:
             raise NotImplementedError("Template functionality not ready")
 
-        # use v1 templates as response same in v1 and v2
-        template = self.response_template(SEND_EMAIL_RESPONSE)
-        return template.render(message=message)
+        return json.dumps({"MessageId": message.id})
 
     def create_contact_list(self) -> str:
         params = json.loads(self.body)


Test patch:
diff --git a/.github/workflows/tests_proxymode.yml b/.github/workflows/tests_proxymode.yml
index af7c799db..9abf533be 100644
--- a/.github/workflows/tests_proxymode.yml
+++ b/.github/workflows/tests_proxymode.yml
@@ -3,11 +3,17 @@ on: [workflow_call]
 
 jobs:
   test:
-    runs-on: ubuntu-latest
     strategy:
       fail-fast: false
       matrix:
+        os: ["ubuntu-latest", "windows-latest"]
         python-version: ["3.10", "3.11", "3.12"]
+        include:
+          - os: ubuntu-latest
+            path: ~/.cache/pip
+          - os: windows-latest
+            path: ~\AppData\Local\pip\Cache
+    runs-on: ${{ matrix.os }}
 
     steps:
     - uses: actions/checkout@v4
@@ -24,8 +30,10 @@ jobs:
     - name: pip cache
       uses: actions/cache@v4
       with:
-        path: ${{ steps.pip-cache.outputs.dir }}
-        key: pip-${{ matrix.python-version }}-${{ hashFiles('**/setup.cfg') }}
+        path: ${{ matrix.path }}
+        key: pip-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/setup.cfg') }}
+        restore-keys: |
+          pip-${{ runner.os }}-${{ matrix.python-version }}-
     - name: Update pip
       run: |
         python -m pip install --upgrade pip
@@ -33,23 +41,38 @@ jobs:
       run: |
         pip install -r requirements-tests.txt
         pip install .[all,server]
-    - name: Start MotoProxy
+    - name: Start MotoProxy on Linux
+      if: ${{ matrix.os == 'ubuntu-latest' }}
       run: |
-        moto_proxy -h > moto_proxy.log
         moto_proxy -H 0.0.0.0 -v > moto_proxy.log &
-    - name: Test ProxyMode
+    - name: Test ProxyMode on Linux
+      if: ${{ matrix.os == 'ubuntu-latest' }}
       env:
         TEST_PROXY_MODE: ${{ true }}
       run: |
         pytest -sv tests/test_acmpca tests/test_athena tests/test_awslambda tests/test_apigateway tests/test_core/test_proxy.py tests/test_s3
+    - name: Test ProxyMode on Windows
+      if: ${{ matrix.os == 'windows-latest' }}
+      shell: pwsh
+      env:
+        TEST_PROXY_MODE: ${{ true }}
+      # We should start moto_proxy in a different step
+      # But I can't keep the process alive, across different steps
+      # It does run in the background - but it looks like the process is killed at the end of the step
+      # Starting the proxy in the same step has the same outcome though, even if it's not pretty
+      #
+      # This step does not test everything, because it takes ages on Windows for some reason
+      run: |
+        moto_proxy -v &
+        pytest -sv tests/test_acmpca tests/test_core/test_proxy.py
     - name: "Stop MotoProxy"
-      if: always()
+      if: ${{ matrix.os == 'ubuntu-latest' }}
       run: |
         kill $(lsof -t -i:5005)
     - name: Archive Proxy logs
       if: always()
       uses: actions/upload-artifact@v4
       with:
-        name: motoproxy-${{ matrix.python-version }}
+        name: motoproxy-${{ matrix.os }}-${{ matrix.python-version }}
         path: |
           moto_proxy.log
\ No newline at end of file
diff --git a/tests/test_athena/__init__.py b/tests/test_athena/__init__.py
index 08a1c1568..1822c621a 100644
--- a/tests/test_athena/__init__.py
+++ b/tests/test_athena/__init__.py
@@ -1,1 +1,59 @@
-# This file is intentionally left blank.
+import os
+from functools import wraps
+from uuid import uuid4
+
+import boto3
+
+from moto import mock_aws
+from moto.s3.responses import DEFAULT_REGION_NAME
+from tests.test_s3 import empty_bucket
+
+
+def athena_aws_verified(func):
+    """
+    Function that is verified to work against AWS.
+    Can be run against AWS at any time by setting:
+      MOTO_TEST_ALLOW_AWS_REQUEST=true
+
+    If this environment variable is not set, the function runs in a `mock_aws` context.
+
+    This decorator will:
+      - Create a bucket
+      - Run the test and pass the bucket_name as an argument
+      - Delete the objects and the bucket itself
+    """
+
+    @wraps(func)
+    def pagination_wrapper():
+        bucket_name = str(uuid4())
+
+        allow_aws_request = (
+            os.environ.get("MOTO_TEST_ALLOW_AWS_REQUEST", "false").lower() == "true"
+        )
+
+        if allow_aws_request:
+            resp = create_bucket_and_test(bucket_name, is_aws=True)
+        else:
+            with mock_aws():
+                resp = create_bucket_and_test(bucket_name, is_aws=False)
+        return resp
+
+    def create_bucket_and_test(bucket_name: str, is_aws: bool):
+        client = boto3.client("s3", region_name=DEFAULT_REGION_NAME)
+
+        client.create_bucket(Bucket=bucket_name)
+        client.put_bucket_tagging(
+            Bucket=bucket_name,
+            Tagging={"TagSet": [{"Key": "environment", "Value": "moto_tests"}]},
+        )
+        try:
+            resp = func(bucket_name, is_aws)
+        finally:
+            ### CLEANUP ###
+
+            empty_bucket(client, bucket_name)
+            client.delete_bucket(Bucket=bucket_name)
+
+        return resp
+
+    return pagination_wrapper
diff --git a/tests/test_athena/test_athena_integration.py b/tests/test_athena/test_athena_integration.py
new file mode 100644
index 000000000..6af2ffca8
--- /dev/null
+++ b/tests/test_athena/test_athena_integration.py
@@ -0,0 +1,155 @@
+import json
+from unittest import SkipTest
+from uuid import uuid4
+
+import boto3
+import pytest
+import requests
+
+from moto import settings
+from tests.test_athena import athena_aws_verified
+
+DATA = ""
+DATA += json.dumps({"k1": "r1_v1", "k2": "r1_v2", "k3": 1}) + "\n"
+DATA += json.dumps({"k1": "r2_v1", "k2": "r2_v2", "k3": 2}) + "\n"
+DATA += json.dumps({"k1": "r3_v1", "k2": "r3_v2"})
+
+ROWS_DATA = [
+    {"Data": [{"VarCharValue": "k1"}, {"VarCharValue": "k2"}, {"VarCharValue": "k3"}]},
+    {
+        "Data": [
+            {"VarCharValue": "r1_v1"},
+            {"VarCharValue": "r1_v2"},
+            {"VarCharValue": "1"},
+        ]
+    },
+    {
+        "Data": [
+            {"VarCharValue": "r2_v1"},
+            {"VarCharValue": "r2_v2"},
+            {"VarCharValue": "2"},
+        ]
+    },
+    {"Data": [{"VarCharValue": "r3_v1"}, {"VarCharValue": "r3_v2"}, {}]},
+]
+COLUMN_INFO = [
+    {
+        "CatalogName": "hive",
+        "SchemaName": "",
+        "TableName": "",
+        "Name": "k1",
+        "Label": "k1",
+        "Type": "varchar",
+        "Precision": 2147483647,
+        "Scale": 0,
+        "Nullable": "UNKNOWN",
+        "CaseSensitive": True,
+    },
+    {
+        "CatalogName": "hive",
+        "SchemaName": "",
+        "TableName": "",
+        "Name": "k2",
+        "Label": "k2",
+        "Type": "varchar",
+        "Precision": 2147483647,
+        "Scale": 0,
+        "Nullable": "UNKNOWN",
+        "CaseSensitive": True,
+    },
+    {
+        "CatalogName": "hive",
+        "SchemaName": "",
+        "TableName": "",
+        "Name": "k3",
+        "Label": "k3",
+        "Type": "integer",
+        "Precision": 10,
+        "Scale": 0,
+        "Nullable": "UNKNOWN",
+        "CaseSensitive": False,
+    },
+]
+
+
+@athena_aws_verified
+@pytest.mark.aws_verified
+def test_athena_csv_result(bucket_name=None, is_aws: bool = False):
+    if not settings.TEST_DECORATOR_MODE:
+        raise SkipTest("No point in testing this outside of decorators")
+
+    s3 = boto3.client("s3", "us-east-1")
+    s3.put_object(Bucket=bucket_name, Key="input/data1.json", Body=DATA.encode("utf-8"))
+
+    athena = boto3.client("athena", "us-east-1")
+
+    config = {"OutputLocation": f"s3://{bucket_name}/output/"}
+    db_name = f"db_{str(uuid4())[0:6]}"
+
+    athena.start_query_execution(
+        QueryString=f"create database {db_name}", ResultConfiguration=config
+    )
+
+    context = {"Database": db_name}
+    CREATE_TABLE = f"""CREATE EXTERNAL TABLE mytable (
+k1 string,
+k2 string,
+k3 int
+) 
+ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe'
+STORED AS INPUTFORMAT 'org.apache.hadoop.mapred.TextInputFormat'
+OUTPUTFORMAT 'org.apache.hadoop.hive.ql.io.IgnoreKeyTextOutputFormat'
+LOCATION 's3://{bucket_name}/input/'; """
+    get_query_results(CREATE_TABLE, athena, config, context)
+
+    if not is_aws:
+        athena_result = {
+            "results": [
+                {
+                    "rows": ROWS_DATA,
+                    "column_info": COLUMN_INFO,
+                }
+            ]
+        }
+        requests.post(
+            "http://motoapi.amazonaws.com/moto-api/static/athena/query-results",
+            json=athena_result,
+        )
+
+    query_exec_id, resp = get_query_results(
+        "SELECT * FROM mytable", athena, config, context
+    )
+    assert resp["ResultSet"] == {
+        "Rows": ROWS_DATA,
+        "ResultSetMetadata": {"ColumnInfo": COLUMN_INFO},
+    }
+
+    file = s3.get_object(Bucket=bucket_name, Key=f"output/{query_exec_id}.csv")
+    assert (
+        file["Body"].read()
+        == b'"k1","k2","k3"\n"r1_v1","r1_v2","1"\n"r2_v1","r2_v2","2"\n"r3_v1","r3_v2",\n'
+    )
+
+    get_query_results("DROP TABLE mytable", athena, config, context)
+
+    get_query_results(f"DROP database {db_name}", athena, config, context)
+
+
+def get_query_results(CREATE_TABLE, athena, config, context):
+    resp = athena.start_query_execution(
+        QueryString=CREATE_TABLE,
+        QueryExecutionContext=context,
+        ResultConfiguration=config,
+    )
+    query_execution_id = resp["QueryExecutionId"]
+
+    status = ""
+    while status not in ["SUCCEEDED", "FAILED"]:
+        resp = athena.get_query_execution(QueryExecutionId=query_execution_id)[
+            "QueryExecution"
+        ]
+
+        status = resp["Status"]["State"]
+
+    resp = athena.get_query_results(QueryExecutionId=query_execution_id)
+    return query_execution_id, resp
diff --git a/tests/test_ec2/test_security_groups.py b/tests/test_ec2/test_security_groups.py
index 7f0147ce2..73a371e1b 100644
--- a/tests/test_ec2/test_security_groups.py
+++ b/tests/test_ec2/test_security_groups.py
@@ -840,20 +840,48 @@ def test_description_in_ip_permissions():
         {
             "IpProtocol": "tcp",
             "FromPort": 27017,
-            "ToPort": 27017,
-            "IpRanges": [{"CidrIp": "1.2.3.4/32", "Description": "testDescription"}],
+            "ToPort": 27018,
+            "IpRanges": [
+                {"CidrIp": "1.2.3.4/32", "Description": "austin"},
+                {"CidrIp": "2.3.4.5/32", "Description": "powers"},
+            ],
         }
     ]
     conn.authorize_security_group_ingress(
         GroupId=sg["GroupId"], IpPermissions=ip_permissions
     )
 
+    result = conn.describe_security_group_rules(
+        Filters=[{"Name": "group-id", "Values": [sg["GroupId"]]}]
+    )
+    assert len(result["SecurityGroupRules"]) == 3
+
+    assert result["SecurityGroupRules"][0]["Description"] == "austin"
+    assert result["SecurityGroupRules"][0]["CidrIpv4"] == "1.2.3.4/32"
+    assert result["SecurityGroupRules"][0]["IsEgress"] is False
+    assert result["SecurityGroupRules"][0]["FromPort"] == 27017
+    assert result["SecurityGroupRules"][0]["ToPort"] == 27018
+
+    assert result["SecurityGroupRules"][1]["Description"] == "powers"
+    assert result["SecurityGroupRules"][1]["CidrIpv4"] == "2.3.4.5/32"
+    assert result["SecurityGroupRules"][1]["IsEgress"] is False
+    assert result["SecurityGroupRules"][1]["FromPort"] == 27017
+    assert result["SecurityGroupRules"][1]["ToPort"] == 27018
+
+    assert result["SecurityGroupRules"][2]["Description"] == ""
+    assert result["SecurityGroupRules"][2]["IsEgress"] is True
+    assert result["SecurityGroupRules"][2]["FromPort"] == -1
+    assert result["SecurityGroupRules"][2]["ToPort"] == -1
+
     result = conn.describe_security_groups(GroupIds=[sg["GroupId"]])
     group = result["SecurityGroups"][0]
 
-    assert group["IpPermissions"][0]["IpRanges"][0]["Description"] == "testDescription"
+    assert group["IpPermissions"][0]["IpRanges"][0]["Description"] == "austin"
     assert group["IpPermissions"][0]["IpRanges"][0]["CidrIp"] == "1.2.3.4/32"
 
+    assert group["IpPermissions"][0]["IpRanges"][1]["Description"] == "powers"
+    assert group["IpPermissions"][0]["IpRanges"][1]["CidrIp"] == "2.3.4.5/32"
+
     sg = conn.create_security_group(
         GroupName="sg2", Description="Test security group sg1", VpcId=vpc.id
     )
@@ -861,9 +889,9 @@ def test_description_in_ip_permissions():
     ip_permissions = [
         {
             "IpProtocol": "tcp",
-            "FromPort": 27017,
-            "ToPort": 27017,
-            "IpRanges": [{"CidrIp": "1.2.3.4/32"}],
+            "FromPort": 27019,
+            "ToPort": 27019,
+            "IpRanges": [{"CidrIp": "3.4.5.6/32"}],
         }
     ]
     conn.authorize_security_group_ingress(
@@ -874,7 +902,7 @@ def test_description_in_ip_permissions():
     group = result["SecurityGroups"][0]
 
     assert group["IpPermissions"][0]["IpRanges"][0].get("Description") is None
-    assert group["IpPermissions"][0]["IpRanges"][0]["CidrIp"] == "1.2.3.4/32"
+    assert group["IpPermissions"][0]["IpRanges"][0]["CidrIp"] == "3.4.5.6/32"
 
 
 @mock_aws
diff --git a/tests/test_sesv2/test_sesv2.py b/tests/test_sesv2/test_sesv2.py
index 0a0a37d19..b9e951401 100644
--- a/tests/test_sesv2/test_sesv2.py
+++ b/tests/test_sesv2/test_sesv2.py
@@ -41,7 +41,7 @@ def test_send_email(ses_v1):  # pylint: disable=redefined-outer-name
     assert e.value.response["Error"]["Code"] == "MessageRejected"
 
     ses_v1.verify_domain_identity(Domain="example.com")
-    conn.send_email(**kwargs)
+    resp = conn.send_email(**kwargs)
     send_quota = ses_v1.get_send_quota()
 
     # Verify
@@ -53,6 +53,8 @@ def test_send_email(ses_v1):  # pylint: disable=redefined-outer-name
         msg: Message = backend.sent_messages[0]
         assert msg.subject == "test subject"
         assert msg.body == "test body"
+        assert "MessageId" in resp
+        assert resp["MessageId"] is not None
 
 
 @mock_aws