Repository: getmoto/moto (python)

Issue:
DynamoDB query with GSI doesn't return LastEvaluatedKey when paging moto 5.0.4 and later
Hi folks,

The failing test is querying with a GSI and pagination.  In this test the `LastEvaluatedKey` is not returned, so in effect doesn't page and return all results.

Found related issues against moto, but none of the fixes seem to work for this specific issue:
* https://github.com/getmoto/moto/issues/7570
* https://github.com/getmoto/moto/issues/7531
* https://github.com/getmoto/moto/issues/7534
* https://github.com/getmoto/moto/issues/7578

I tested all the moto versions and it errors from `5.0.4` onwards:
```
pip install moto[all]==5.0.8 # error
pip install moto[all]==5.0.7 # error
pip install moto[all]==5.0.6 # error
pip install moto[all]==5.0.5 # error
pip install moto[all]==5.0.4 # error
pip install moto[all]==5.0.3 # works
```

Working test `5.0.3`
```
DEBUG    root:test_dynamodb_gsi_pagination_condensed.py:27 last_evaluated_key: {'Artist': {'S': 'Green Day'}, 'SongTitle': {'S': 'song-3'}, 'AlbumTitle': {'S': 'Dookie'}}
DEBUG    root:test_dynamodb_gsi_pagination_condensed.py:27 last_evaluated_key: {'Artist': {'S': 'Green Day'}, 'SongTitle': {'S': 'song-5'}, 'AlbumTitle': {'S': 'Dookie'}}
DEBUG    root:test_dynamodb_gsi_pagination_condensed.py:27 last_evaluated_key: {'Artist': {'S': 'Green Day'}, 'SongTitle': {'S': 'song-7'}, 'AlbumTitle': {'S': 'Dookie'}}
DEBUG    root:test_dynamodb_gsi_pagination_condensed.py:27 last_evaluated_key: {'Artist': {'S': 'Green Day'}, 'SongTitle': {'S': 'song-9'}, 'AlbumTitle': {'S': 'Dookie'}}
DEBUG    root:test_dynamodb_gsi_pagination_condensed.py:27 last_evaluated_key: {'Artist': {'S': 'Green Day'}, 'SongTitle': {'S': 'song-11'}, 'AlbumTitle': {'S': 'Dookie'}}
DEBUG    root:test_dynamodb_gsi_pagination_condensed.py:27 last_evaluated_key: {'Artist': {'S': 'Green Day'}, 'SongTitle': {'S': 'song-13'}, 'AlbumTitle': {'S': 'Dookie'}}
DEBUG    root:test_dynamodb_gsi_pagination_condensed.py:27 last_evaluated_key: {'Artist': {'S': 'Green Day'}, 'SongTitle': {'S': 'song-15'}, 'AlbumTitle': {'S': 'Dookie'}}
DEBUG    root:test_dynamodb_gsi_pagination_condensed.py:27 last_evaluated_key: {'Artist': {'S': 'Green Day'}, 'SongTitle': {'S': 'song-17'}, 'AlbumTitle': {'S': 'Dookie'}}
DEBUG    root:test_dynamodb_gsi_pagination_condensed.py:27 last_evaluated_key: None
DEBUG    root:test_dynamodb_gsi_pagination_condensed.py:28 Retrieved 20 items from dynamodb
```

Not working test `5.0.8`
```
DEBUG    root:test_dynamodb_gsi_pagination_condensed.py:27 last_evaluated_key: None
DEBUG    root:test_dynamodb_gsi_pagination_condensed.py:28 Retrieved 2 items from dynamodb
```

## Test
I'm using `Python 3.12.0`.

To run test:
```
pytest -o log_cli=true ./test_dynamodb_gsi_pagination_condensed.py
```

requirements.txt
```
boto3==1.34.112
botocore==1.34.112
moto[all]==5.0.8
pytest==8.2.1
```

Test code
```python
# pytest -o log_cli=true ./test_dynamodb_gsi_pagination_condensed.py

import os
import logging
import boto3
import pytest
from boto3.dynamodb.types import TypeDeserializer
from moto import mock_aws

logger = logging.getLogger()
logging.basicConfig(
    format="%(asctime)s %(levelname)s - %(name)s %(message)s",
    level=logging.DEBUG,
    force=True,
)
logging.getLogger("boto3").setLevel(logging.WARNING)
logging.getLogger("botocore").setLevel(logging.WARNING)


def query_with_pagination(**kwargs):
    client = boto3.client("dynamodb", region_name="ap-southeast-2")
    response = client.query(**kwargs)
    items = response["Items"]
    last_evaluated_key = response.get("LastEvaluatedKey")
    while last_evaluated_key:
        response = client.query(ExclusiveStartKey=last_evaluated_key, **kwargs)
        items += response["Items"]
        last_evaluated_key = response.get("LastEvaluatedKey")
        logger.debug(f"last_evaluated_key: {last_evaluated_key}")
    logger.debug(f"Retrieved {len(items)} items from dynamodb"),
    return dynamodb_to_python(items) if items else []


def dynamodb_to_python(dynamodb_items: list) -> list:
    return [
        {k: TypeDeserializer().deserialize(v) for k, v in item.items()}
        for item in dynamodb_items
    ]


ACCOUNT_ID, REGION, TABLE_NAME = "123456789012", "ap-southeast-2", "Music"


@pytest.fixture(name="dynamodb_client")
def fixture_dynamodb_client():
    os.environ["AWS_ACCESS_KEY_ID"] = "testing"
    os.environ["AWS_SECRET_ACCESS_KEY"] = "testing"
    os.environ["AWS_SECURITY_TOKEN"] = "testing"
    os.environ["AWS_SESSION_TOKEN"] = "testing"
    os.environ["AWS_DEFAULT_REGION"] = REGION
    with mock_aws():
        yield boto3.client("dynamodb", region_name=REGION)


@mock_aws
def setup_table(dynamodb_client):
    dynamodb_client.create_table(
        AttributeDefinitions=[
            {"AttributeName": name, "AttributeType": "S"}
            for name in ["Artist", "SongTitle", "AlbumTitle"]
        ],
        KeySchema=[
            {"AttributeName": name, "KeyType": type}
            for name, type in [("Artist", "HASH"), ("SongTitle", "RANGE")]
        ],
        GlobalSecondaryIndexes=[
            {
                "IndexName": "AlbumTitleIndex",
                "KeySchema": [{"AttributeName": "AlbumTitle", "KeyType": "HASH"}],
                "Projection": {"ProjectionType": "ALL"},
                "ProvisionedThroughput": {
                    "ReadCapacityUnits": 5,
                    "WriteCapacityUnits": 5,
                },
            }
        ],
        ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5},
        TableName=TABLE_NAME,
    )
    items = [
        {
            "Artist": {"S": "Green Day"},
            "SongTitle": {"S": f"song-{i}"},
            "AlbumTitle": {"S": "Dookie"},
        }
        for i in range(20)
    ]
    dynamodb_client.batch_write_item(
        RequestItems={TABLE_NAME: [{"PutRequest": {"Item": item}} for item in items]}
    )
    return [f"song-{i}" for i in range(20)]  # for assertion


@mock_aws
def test_query_with_pagination(dynamodb_client):
    query_args = {
        "TableName": TABLE_NAME,
        "IndexName": "AlbumTitleIndex",
        "KeyConditionExpression": "AlbumTitle = :AlbumTitle",
        "ExpressionAttributeValues": {":AlbumTitle": {"S": "Dookie"}},
        "Limit": 2,
    }
    expected_items = setup_table(dynamodb_client)
    items = query_with_pagination(**query_args)
    song_titles = [item["SongTitle"] for item in items]
    assert sorted(song_titles) == sorted(expected_items)
    assert len(song_titles) == len(expected_items)
```

Thanks for your time.

Pull request:
DynamoDB: query() should handle GSI's without range key
Fixes #7725 

Patch:
diff --git a/moto/dynamodb/models/table.py b/moto/dynamodb/models/table.py
index e64f5eecb..32a4d3c81 100644
--- a/moto/dynamodb/models/table.py
+++ b/moto/dynamodb/models/table.py
@@ -702,11 +702,12 @@ class Table(CloudFormationModel):
                     key for key in index.schema if key["KeyType"] == "RANGE"
                 ][0]
             except IndexError:
-                if isinstance(index, GlobalSecondaryIndex):
-                    # If we're querying a GSI that does not have an index, the main hash key acts as a range key
-                    index_range_key = {"AttributeName": self.hash_key_attr}
+                if isinstance(index, GlobalSecondaryIndex) and self.range_key_attr:
+                    # If we're querying a GSI that does not have a range key, the main range key acts as a range key
+                    index_range_key = {"AttributeName": self.range_key_attr}
                 else:
-                    index_range_key = None
+                    # If we don't have a range key on the main table either, the hash key acts as a range key
+                    index_range_key = {"AttributeName": self.hash_key_attr}
                 if range_comparison:
                     raise ValueError(
                         f"Range Key comparison but no range key found for index: {index_name}"
diff --git a/moto/iam/responses.py b/moto/iam/responses.py
index 562dd2b1e..cb20c45fd 100644
--- a/moto/iam/responses.py
+++ b/moto/iam/responses.py
@@ -1239,14 +1239,16 @@ CREATE_POLICY_TEMPLATE = """<CreatePolicyResponse>
       <PolicyId>{{ policy.id }}</PolicyId>
       <PolicyName>{{ policy.name }}</PolicyName>
       <UpdateDate>{{ policy.updated_iso_8601 }}</UpdateDate>
+      {% if policy.tags %}
       <Tags>
-        {% for tag_key, tag_value in policy.tags.items() %}
+        {% for tag in policy.get_tags() %}
         <member>
-          <Key>{{ tag_key }}</Key>
-          <Value>{{ tag_value }}</Value>
+            <Key>{{ tag['Key'] }}</Key>
+            <Value>{{ tag['Value'] }}</Value>
         </member>
         {% endfor %}
       </Tags>
+      {% endif %}
     </Policy>
   </CreatePolicyResult>
   <ResponseMetadata>
diff --git a/other_langs/terraform/iam/iam.tf b/other_langs/terraform/iam/iam.tf
new file mode 100644
index 000000000..1a7710d9e
--- /dev/null
+++ b/other_langs/terraform/iam/iam.tf
@@ -0,0 +1,24 @@
+resource "aws_iam_policy" "policy" {
+  name        = "test_policy"
+  path        = "/"
+  description = "My test policy"
+
+  # Terraform's "jsonencode" function converts a
+  # Terraform expression result to valid JSON syntax.
+  policy = jsonencode({
+    Version = "2012-10-17"
+    Statement = [
+      {
+        Action = [
+          "ec2:Describe*",
+        ]
+        Effect   = "Allow"
+        Resource = "*"
+      },
+    ]
+  })
+
+  tags = {
+    t1 = "v1"
+  }
+}
\ No newline at end of file
diff --git a/other_langs/terraform/iam/providers.tf b/other_langs/terraform/iam/providers.tf
new file mode 100644
index 000000000..3e11097e7
--- /dev/null
+++ b/other_langs/terraform/iam/providers.tf
@@ -0,0 +1,14 @@
+provider "aws" {
+  region                      = "us-east-1"
+  s3_use_path_style           = true
+  skip_credentials_validation = true
+  skip_metadata_api_check     = true
+  skip_requesting_account_id  = true
+
+  endpoints {
+    iam = "http://localhost:5000"
+  }
+
+  access_key = "my-access-key"
+  secret_key = "my-secret-key"
+}


Test patch:
diff --git a/.github/workflows/tests_terraform_examples.yml b/.github/workflows/tests_terraform_examples.yml
index 8dfa291b9..fee7afa17 100644
--- a/.github/workflows/tests_terraform_examples.yml
+++ b/.github/workflows/tests_terraform_examples.yml
@@ -11,7 +11,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        service: ["acm", "awslambda", "cloudfront", "elb", "route53", "sqs"]
+        service: ["acm", "awslambda", "cloudfront", "elb", "iam", "route53", "sqs"]
 
     steps:
     - uses: actions/checkout@v4
diff --git a/tests/test_dynamodb/test_dynamodb_query.py b/tests/test_dynamodb/test_dynamodb_query.py
index 71b4b8fc7..79b07b368 100644
--- a/tests/test_dynamodb/test_dynamodb_query.py
+++ b/tests/test_dynamodb/test_dynamodb_query.py
@@ -240,6 +240,46 @@ def test_query_gsi_pagination(table_name=None):
     ]
 
 
+@pytest.mark.aws_verified
+@dynamodb_aws_verified(add_range=True, add_gsi=True)
+def test_query_gsi_pagination_with_string_range(table_name=None):
+    dynamodb = boto3.resource("dynamodb", region_name="us-east-1")
+    table = dynamodb.Table(table_name)
+
+    for i in range(3, 7):
+        table.put_item(Item={"pk": "the-key", "sk": f"{i}", "gsi_pk": "johndoe"})
+
+    for i in range(9, 6, -1):
+        table.put_item(Item={"pk": "the-key", "sk": f"{i}", "gsi_pk": "johndoe"})
+
+    for i in range(3):
+        table.put_item(Item={"pk": "the-key", "sk": f"{i}", "gsi_pk": "johndoe"})
+
+    page1 = table.query(
+        KeyConditionExpression=Key("gsi_pk").eq("johndoe"),
+        IndexName="test_gsi",
+        Limit=6,
+    )
+    assert page1["Count"] == 6
+    assert page1["ScannedCount"] == 6
+    assert len(page1["Items"]) == 6
+
+    page2 = table.query(
+        KeyConditionExpression=Key("gsi_pk").eq("johndoe"),
+        IndexName="test_gsi",
+        Limit=6,
+        ExclusiveStartKey=page1["LastEvaluatedKey"],
+    )
+    assert page2["Count"] == 4
+    assert page2["ScannedCount"] == 4
+    assert len(page2["Items"]) == 4
+    assert "LastEvaluatedKey" not in page2
+
+    results = page1["Items"] + page2["Items"]
+    subjects = set([int(r["sk"]) for r in results])
+    assert subjects == set(range(10))
+
+
 @pytest.mark.aws_verified
 @dynamodb_aws_verified(add_range=True, numeric_gsi_range=True)
 def test_query_gsi_pagination_with_numeric_range(table_name=None):
diff --git a/tests/test_iam/test_iam.py b/tests/test_iam/test_iam.py
index 9b00a7dbe..507377bc3 100644
--- a/tests/test_iam/test_iam.py
+++ b/tests/test_iam/test_iam.py
@@ -858,25 +858,24 @@ def test_delete_default_policy_version():
 @mock_aws()
 def test_create_policy_with_tags():
     conn = boto3.client("iam", region_name="us-east-1")
-    conn.create_policy(
+    tag1 = {"Key": "somekey", "Value": "somevalue"}
+    tag2 = {"Key": "someotherkey", "Value": "someothervalue"}
+    create = conn.create_policy(
         PolicyName="TestCreatePolicyWithTags1",
         PolicyDocument=MOCK_POLICY,
-        Tags=[
-            {"Key": "somekey", "Value": "somevalue"},
-            {"Key": "someotherkey", "Value": "someothervalue"},
-        ],
+        Tags=[tag1, tag2],
         Description="testing",
-    )
+    )["Policy"]
+    assert tag1 in create["Tags"]
+    assert tag2 in create["Tags"]
 
     # Get policy:
     policy = conn.get_policy(
         PolicyArn=f"arn:aws:iam::{ACCOUNT_ID}:policy/TestCreatePolicyWithTags1"
     )["Policy"]
     assert len(policy["Tags"]) == 2
-    assert policy["Tags"][0]["Key"] == "somekey"
-    assert policy["Tags"][0]["Value"] == "somevalue"
-    assert policy["Tags"][1]["Key"] == "someotherkey"
-    assert policy["Tags"][1]["Value"] == "someothervalue"
+    assert tag1 in policy["Tags"]
+    assert tag2 in policy["Tags"]
     assert policy["Description"] == "testing"