Repository: getmoto/moto
Pull request #8292 (2024-11-04 20:16:26)

IOT RoleAlias support - API and CloudFormation object, enhancements to search_index() response
In this PR I'd like to contribute:
* IoT RoleAlias API: `create_role_alias()`, `list_role_aliases()`, `describe_role_alias()`, `update_role_alias()`, `delete_role_alias()`
* `AWS::IoT::RoleAlias` CloudFormation object

Enhance search() index response - add missing `thingGroupNames` and `shadow` fields

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getmoto/moto/pull/8292?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 `98.90110%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 94.56%. Comparing base [(`bb27c62`)](https://app.codecov.io/gh/getmoto/moto/commit/bb27c621ca91e3f24307ea92d9398ebbb90baa23?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) to head [(`7ed34fd`)](https://app.codecov.io/gh/getmoto/moto/commit/7ed34fd2efd6c43e1fdd98b5948a6da6aba81b8b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).
> Report is 3 commits behind head on master.

| [Files with missing lines](https://app.codecov.io/gh/getmoto/moto/pull/8292?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/iot/models.py](https://app.codecov.io/gh/getmoto/moto/pull/8292?src=pr&el=tree&filepath=moto%2Fiot%2Fmodels.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#diff-bW90by9pb3QvbW9kZWxzLnB5) | 98.55% | [1 Missing :warning: ](https://app.codecov.io/gh/getmoto/moto/pull/8292?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    #8292   +/-   ##
=======================================
  Coverage   94.56%   94.56%           
=======================================
  Files        1158     1158           
  Lines      100744   100835   +91     
=======================================
+ Hits        95264    95357   +93     
+ Misses       5480     5478    -2     
```

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

moto-payments-app[bot]: Hi @jsuchenia,

Thank you so much for contributing to Moto!

To show our thanks, we'd like to share some of the donations that we've received with you. PR's like this are the big reason that Moto is as successful as it is - so it's only fair that you, as a contributor, get to share the spoils.

We've created a companion website with more information:
https://payments.getmoto.org/

Feel free to open a bug or discussion if you run into any problems:
https://github.com/getmoto/payments

Patch:
diff --git a/CLOUDFORMATION_COVERAGE.md b/CLOUDFORMATION_COVERAGE.md
index b7853a20f..34bcedc0a 100644
--- a/CLOUDFORMATION_COVERAGE.md
+++ b/CLOUDFORMATION_COVERAGE.md
@@ -277,6 +277,11 @@ Please let us know if you'd like support for a resource not yet listed here.
    - [x] update implemented
    - [x] delete implemented
    - [x] Fn::GetAtt implemented
+- AWS::IoT::RoleAlias:
+   - [x] create implemented
+   - [x] update implemented
+   - [x] delete implemented
+   - [x] Fn::GetAtt implemented
 - AWS::KMS::Key:
    - [x] create implemented
    - [ ] update implemented
diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md
index 1626ede03..e5ee66fff 100644
--- a/IMPLEMENTATION_COVERAGE.md
+++ b/IMPLEMENTATION_COVERAGE.md
@@ -4539,7 +4539,7 @@
 - [ ] create_provisioning_claim
 - [ ] create_provisioning_template
 - [ ] create_provisioning_template_version
-- [ ] create_role_alias
+- [X] create_role_alias
 - [ ] create_scheduled_audit
 - [ ] create_security_profile
 - [ ] create_stream
@@ -4572,7 +4572,7 @@
 - [ ] delete_provisioning_template
 - [ ] delete_provisioning_template_version
 - [ ] delete_registration_code
-- [ ] delete_role_alias
+- [X] delete_role_alias
 - [ ] delete_scheduled_audit
 - [ ] delete_security_profile
 - [ ] delete_stream
@@ -4609,7 +4609,7 @@
 - [ ] describe_mitigation_action
 - [ ] describe_provisioning_template
 - [ ] describe_provisioning_template_version
-- [ ] describe_role_alias
+- [X] describe_role_alias
 - [ ] describe_scheduled_audit
 - [ ] describe_security_profile
 - [ ] describe_stream
@@ -4682,7 +4682,7 @@
 - [ ] list_provisioning_template_versions
 - [ ] list_provisioning_templates
 - [ ] list_related_resources_for_audit_finding
-- [ ] list_role_aliases
+- [X] list_role_aliases
 - [ ] list_sbom_validation_results
 - [ ] list_scheduled_audits
 - [ ] list_security_profiles
@@ -4749,7 +4749,7 @@
 - [ ] update_package_configuration
 - [ ] update_package_version
 - [ ] update_provisioning_template
-- [ ] update_role_alias
+- [X] update_role_alias
 - [ ] update_scheduled_audit
 - [ ] update_security_profile
 - [ ] update_stream
diff --git a/moto/iot/models.py b/moto/iot/models.py
index 87746f6a4..a92765c95 100644
--- a/moto/iot/models.py
+++ b/moto/iot/models.py
@@ -48,6 +48,7 @@ class FakeThing(CloudFormationModel):
         region_name: str,
     ):
         self.region_name = region_name
+        self.account_id = account_id
         self.thing_id = str(random.uuid4())
         self.thing_name = thing_name
         self.thing_type = thing_type
@@ -80,6 +81,8 @@ class FakeThing(CloudFormationModel):
         include_default_client_id: bool = False,
         include_connectivity: bool = False,
         include_thing_id: bool = False,
+        include_thing_group_names: bool = False,
+        include_shadows_as_json: bool = False,
     ) -> Dict[str, Any]:
         obj = {
             "thingName": self.thing_name,
@@ -98,6 +101,29 @@ class FakeThing(CloudFormationModel):
             }
         if include_thing_id:
             obj["thingId"] = self.thing_id
+        if include_thing_group_names:
+            iot_backend = iot_backends[self.account_id][self.region_name]
+            obj["thingGroupNames"] = [
+                thing_group.thing_group_name
+                for thing_group in iot_backend.list_thing_groups(None, None, None)
+                if self.arn in thing_group.things
+            ]
+        if include_shadows_as_json:
+            named_shadows = {
+                shadow_name: shadow.to_dict()
+                for shadow_name, shadow in self.thing_shadows.items()
+                if shadow_name is not None
+            }
+            converted_response_format = {
+                shadow_name: {
+                    **shadow_data["state"],
+                    "metadata": shadow_data["metadata"],
+                    "version": shadow_data["version"],
+                    "hasDelta": "delta" in shadow_data["state"],
+                }
+                for shadow_name, shadow_data in named_shadows.items()
+            }
+            obj["shadow"] = json.dumps({"name": converted_response_format})
         return obj
 
     @staticmethod
@@ -899,6 +925,108 @@ class FakeDomainConfiguration(BaseModel):
         }
 
 
+class FakeRoleAlias(CloudFormationModel):
+    def __init__(
+        self,
+        role_alias: str,
+        role_arn: str,
+        account_id: str,
+        region_name: str,
+        credential_duration_seconds: int = 3600,
+    ):
+        self.role_alias = role_alias
+        self.role_arn = role_arn
+        self.credential_duration_seconds = credential_duration_seconds
+        self.account_id = account_id
+        self.region_name = region_name
+        self.creation_date = time.time()
+        self.last_modified_date = self.creation_date
+        self.arn = f"arn:{get_partition(self.region_name)}:iot:{self.region_name}:{self.account_id}:rolealias/{role_alias}"
+
+    def to_dict(self) -> Dict[str, Any]:
+        return {
+            "roleAlias": self.role_alias,
+            "roleAliasArn": self.arn,
+            "roleArn": self.role_arn,
+            "owner": self.account_id,
+            "credentialDurationSeconds": self.credential_duration_seconds,
+            "creationDate": self.creation_date,
+            "lastModifiedDate": self.last_modified_date,
+        }
+
+    @staticmethod
+    def cloudformation_name_type() -> str:
+        return "RoleAlias"
+
+    @staticmethod
+    def cloudformation_type() -> str:
+        return "AWS::IoT::RoleAlias"
+
+    @classmethod
+    def has_cfn_attr(cls, attr: str) -> bool:
+        return attr in [
+            "RoleAliasArn",
+        ]
+
+    def get_cfn_attribute(self, attribute_name: str) -> Any:
+        from moto.cloudformation.exceptions import UnformattedGetAttTemplateException
+
+        if attribute_name == "RoleAliasArn":
+            return self.arn
+        raise UnformattedGetAttTemplateException()
+
+    @classmethod
+    def create_from_cloudformation_json(  # type: ignore[misc]
+        cls,
+        resource_name: str,
+        cloudformation_json: Any,
+        account_id: str,
+        region_name: str,
+        **kwargs: Any,
+    ) -> "FakeRoleAlias":
+        iot_backend = iot_backends[account_id][region_name]
+        properties = cloudformation_json["Properties"]
+
+        role_alias_name = properties.get("RoleAlias", resource_name)
+        role_arn = properties.get("RoleArn")
+        credential_duration_seconds = properties.get("CredentialDurationSeconds", 3600)
+
+        return iot_backend.create_role_alias(
+            role_alias_name=role_alias_name,
+            role_arn=role_arn,
+            credential_duration_seconds=credential_duration_seconds,
+        )
+
+    @classmethod
+    def update_from_cloudformation_json(  # type: ignore[misc]
+        cls,
+        original_resource: "FakeRoleAlias",
+        new_resource_name: str,
+        cloudformation_json: Any,
+        account_id: str,
+        region_name: str,
+    ) -> "FakeRoleAlias":
+        iot_backend = iot_backends[account_id][region_name]
+        iot_backend.delete_role_alias(role_alias_name=original_resource.role_alias)
+        return cls.create_from_cloudformation_json(
+            new_resource_name, cloudformation_json, account_id, region_name
+        )
+
+    @classmethod
+    def delete_from_cloudformation_json(  # type: ignore[misc]
+        cls,
+        resource_name: str,
+        cloudformation_json: Any,
+        account_id: str,
+        region_name: str,
+    ) -> None:
+        properties = cloudformation_json["Properties"]
+        role_alias_name = properties.get("RoleAlias", resource_name)
+
+        iot_backend = iot_backends[account_id][region_name]
+        iot_backend.delete_role_alias(role_alias_name=role_alias_name)
+
+
 class IoTBackend(BaseBackend):
     def __init__(self, region_name: str, account_id: str):
         super().__init__(region_name, account_id)
@@ -917,6 +1045,7 @@ class IoTBackend(BaseBackend):
             OrderedDict()
         )
         self.rules: Dict[str, FakeRule] = OrderedDict()
+        self.role_aliases: Dict[str, FakeRoleAlias] = OrderedDict()
         self.endpoint: Optional[FakeEndpoint] = None
         self.domain_configurations: Dict[str, FakeDomainConfiguration] = OrderedDict()
 
@@ -2195,7 +2324,65 @@ class IoTBackend(BaseBackend):
         things = [
             thing for thing in self.things.values() if thing.matches(query_string)
         ]
-        return [t.to_dict(include_connectivity=True) for t in things]
+        return [
+            t.to_dict(
+                include_connectivity=True,
+                include_thing_id=True,
+                include_thing_group_names=True,
+                include_shadows_as_json=True,
+            )
+            for t in things
+        ]
+
+    def create_role_alias(
+        self,
+        role_alias_name: str,
+        role_arn: str,
+        credential_duration_seconds: int = 3600,
+    ) -> FakeRoleAlias:
+        if role_alias_name in self.role_aliases:
+            current_role_alias = self.role_aliases[role_alias_name]
+            raise ResourceAlreadyExistsException(
+                f"RoleAlias cannot be created - already exists (name={role_alias_name})",
+                current_role_alias.role_alias,
+                current_role_alias.arn,
+            )
+        new_role_alias = FakeRoleAlias(
+            role_alias=role_alias_name,
+            role_arn=role_arn,
+            account_id=self.account_id,
+            region_name=self.region_name,
+            credential_duration_seconds=credential_duration_seconds,
+        )
+        self.role_aliases[role_alias_name] = new_role_alias
+        return new_role_alias
+
+    def list_role_aliases(self) -> Iterable[FakeRoleAlias]:
+        return self.role_aliases.values()
+
+    def describe_role_alias(self, role_alias_name: str) -> FakeRoleAlias:
+        if role_alias_name not in self.role_aliases:
+            raise ResourceNotFoundException(
+                f"RoleAlias not found (name= {role_alias_name})",
+            )
+        return self.role_aliases[role_alias_name]
+
+    def update_role_alias(
+        self,
+        role_alias_name: str,
+        role_arn: Optional[str] = None,
+        credential_duration_seconds: Optional[int] = None,
+    ) -> FakeRoleAlias:
+        role_alias = self.describe_role_alias(role_alias_name=role_alias_name)
+        if role_arn:
+            role_alias.role_arn = role_arn
+        if credential_duration_seconds:
+            role_alias.credential_duration_seconds = credential_duration_seconds
+        return role_alias
+
+    def delete_role_alias(self, role_alias_name: str) -> None:
+        self.describe_role_alias(role_alias_name=role_alias_name)
+        del self.role_aliases[role_alias_name]
 
 
 iot_backends = BackendDict(IoTBackend, "iot")
diff --git a/moto/iot/responses.py b/moto/iot/responses.py
index f4bb04e72..7d256dbb6 100644
--- a/moto/iot/responses.py
+++ b/moto/iot/responses.py
@@ -769,3 +769,60 @@ class IoTResponse(BaseResponse):
         query = self._get_param("queryString")
         things = self.iot_backend.search_index(query)
         return json.dumps({"things": things, "thingGroups": []})
+
+    def create_role_alias(self) -> str:
+        role_alias_name = self._get_param("roleAlias")
+        role_arn = self._get_param("roleArn")
+        credential_duration_seconds = self._get_int_param(
+            "credentialDurationSeconds", 3600
+        )
+        created_role_alias = self.iot_backend.create_role_alias(
+            role_alias_name=role_alias_name,
+            role_arn=role_arn,
+            credential_duration_seconds=credential_duration_seconds,
+        )
+        return json.dumps(
+            dict(
+                roleAlias=created_role_alias.role_alias,
+                roleAliasArn=created_role_alias.arn,
+            )
+        )
+
+    def list_role_aliases(self) -> str:
+        # page_size = self._get_int_param("pageSize")
+        # marker = self._get_param("marker")
+        # ascending_order = self._get_param("ascendingOrder")
+        return json.dumps(
+            dict(
+                roleAliases=[_.role_alias for _ in self.iot_backend.list_role_aliases()]
+            )
+        )
+
+    def describe_role_alias(self) -> str:
+        role_alias_name = self._get_param("roleAlias")
+        role_alias = self.iot_backend.describe_role_alias(
+            role_alias_name=role_alias_name
+        )
+        return json.dumps({"roleAliasDescription": role_alias.to_dict()})
+
+    def update_role_alias(self) -> str:
+        role_alias_name = self._get_param("roleAlias")
+        role_arn = self._get_param("roleArn", None)
+        credential_duration_seconds = self._get_int_param(
+            "credentialDurationSeconds", 0
+        )
+
+        role_alias = self.iot_backend.update_role_alias(
+            role_alias_name=role_alias_name,
+            role_arn=role_arn,
+            credential_duration_seconds=credential_duration_seconds,
+        )
+
+        return json.dumps(
+            dict(roleAlias=role_alias.role_alias, roleAliasArn=role_alias.arn)
+        )
+
+    def delete_role_alias(self) -> str:
+        role_alias_name = self._get_param("roleAlias")
+        self.iot_backend.delete_role_alias(role_alias_name=role_alias_name)
+        return json.dumps({})


Test patch:
diff --git a/tests/test_iot/test_iot_cloudformation.py b/tests/test_iot/test_iot_cloudformation.py
index d30fbdbc2..27199d2cb 100644
--- a/tests/test_iot/test_iot_cloudformation.py
+++ b/tests/test_iot/test_iot_cloudformation.py
@@ -552,3 +552,136 @@ def test_delete_stack_should_delete_policy():
     # then delete stack
     cfn_conn.delete_stack(StackName=stack_name)
     assert len(iot_conn.list_policies()["policies"]) == 0
+
+
+@mock_aws
+def test_create_role_alias_with_simple_cloudformation():
+    # given
+    stack_name = "test_stack"
+    template = {
+        "AWSTemplateFormatVersion": "2010-09-09",
+        "Description": "IOT RoleAlias CloudFormation",
+        "Resources": {
+            "testRoleAlias": {
+                "Type": "AWS::IoT::RoleAlias",
+                "Properties": {
+                    "RoleAlias": "TestRoleAlias",
+                    "RoleArn": "arn:aws:iam::123456789012:role/my-role",
+                },
+            },
+        },
+        "Outputs": {
+            "RoleAliasArn": {
+                "Value": {"Fn::GetAtt": ["testRoleAlias", "RoleAliasArn"]}
+            },
+        },
+    }
+
+    # when
+    cfn_conn = boto3.client("cloudformation", region_name=TEST_REGION)
+    cfn_conn.create_stack(StackName=stack_name, TemplateBody=json.dumps(template))
+
+    # then check list of things
+    iot_conn = boto3.client("iot", region_name=TEST_REGION)
+    assert iot_conn.list_role_aliases()["roleAliases"] == ["TestRoleAlias"]
+
+    # then check stack
+    stack = cfn_conn.describe_stacks(StackName=stack_name)["Stacks"][0]
+    outputs = {
+        Output["OutputKey"]: Output["OutputValue"] for Output in stack["Outputs"]
+    }
+    assert (
+        outputs["RoleAliasArn"]
+        == "arn:aws:iot:us-west-1:123456789012:rolealias/TestRoleAlias"
+    )
+
+
+@mock_aws
+def test_update_role_alias_with_cloudformation():
+    # given
+    stack_name = "test_stack"
+    initial_template = {
+        "AWSTemplateFormatVersion": "2010-09-09",
+        "Description": "IOT RoleAlias CloudFormation",
+        "Resources": {
+            "testRoleAlias": {
+                "Type": "AWS::IoT::RoleAlias",
+                "Properties": {"RoleArn": "arn:aws:iam::123456789012:role/my-role"},
+            },
+        },
+    }
+    updated_template = {
+        "AWSTemplateFormatVersion": "2010-09-09",
+        "Description": "IOT RoleAlias CloudFormation",
+        "Resources": {
+            "testRoleAlias": {
+                "Type": "AWS::IoT::RoleAlias",
+                "Properties": {
+                    "RoleAlias": "TestRoleAlias",
+                    "RoleArn": "arn:aws:iam::123456789012:role/other-role",
+                    "CredentialDurationSeconds": 1234,
+                },
+            },
+        },
+    }
+
+    # when
+    cfn_conn = boto3.client("cloudformation", region_name=TEST_REGION)
+    cfn_conn.create_stack(
+        StackName=stack_name, TemplateBody=json.dumps(initial_template)
+    )
+
+    # then check list of role aliases
+    iot_conn = boto3.client("iot", region_name=TEST_REGION)
+    role_aliases = iot_conn.list_role_aliases()["roleAliases"]
+    assert len(role_aliases) == 1
+    assert role_aliases[0].startswith("test_stack-testRoleAlias-")
+
+    # then check stack
+    cfn_conn.update_stack(
+        StackName=stack_name, TemplateBody=json.dumps(updated_template)
+    )
+    assert iot_conn.list_role_aliases()["roleAliases"] == ["TestRoleAlias"]
+
+    # and describe that role alias
+    role_alias = iot_conn.describe_role_alias(roleAlias="TestRoleAlias")
+    assert role_alias["roleAliasDescription"]["roleAlias"] == "TestRoleAlias"
+    assert (
+        role_alias["roleAliasDescription"]["roleAliasArn"]
+        == "arn:aws:iot:us-west-1:123456789012:rolealias/TestRoleAlias"
+    )
+    assert (
+        role_alias["roleAliasDescription"]["roleArn"]
+        == "arn:aws:iam::123456789012:role/other-role"
+    )
+
+
+@mock_aws
+def test_delete_role_alias_with_cloudformation():
+    # given
+    stack_name = "test_stack"
+    template = {
+        "AWSTemplateFormatVersion": "2010-09-09",
+        "Description": "IOT RoleAlias CloudFormation",
+        "Resources": {
+            "testRoleAlias": {
+                "Type": "AWS::IoT::RoleAlias",
+                "Properties": {
+                    "RoleAlias": "TestRoleAlias",
+                    "RoleArn": "arn:aws:iam::123456789012:role/my-role",
+                },
+            },
+        },
+    }
+
+    # when
+    cfn_conn = boto3.client("cloudformation", region_name=TEST_REGION)
+    cfn_conn.create_stack(StackName=stack_name, TemplateBody=json.dumps(template))
+
+    # then check list of role aliases
+    iot_conn = boto3.client("iot", region_name=TEST_REGION)
+    assert len(iot_conn.list_role_aliases()["roleAliases"]) == 1
+
+    # then check stack
+    cfn_conn.delete_stack(StackName=stack_name)
+    assert iot_conn.list_role_aliases()["roleAliases"] == []
diff --git a/tests/test_iot/test_iot_rolealias.py b/tests/test_iot/test_iot_rolealias.py
new file mode 100644
index 000000000..387e4e49c
--- /dev/null
+++ b/tests/test_iot/test_iot_rolealias.py
@@ -0,0 +1,128 @@
+import boto3
+import pytest
+
+from moto import mock_aws
+
+
+@pytest.fixture(name="client")
+def client_fixture():
+    with mock_aws():
+        yield boto3.client("iot", region_name="eu-west-1")
+
+
+def test_create_role_alias(client):
+    role_alias_name = "test-role-alias"
+    create_response = client.create_role_alias(
+        roleAlias=role_alias_name,
+        roleArn="arn:aws:iam::123456789012:role/my-role",
+        credentialDurationSeconds=1234,
+    )
+
+    assert create_response["roleAlias"] == role_alias_name
+    assert (
+        create_response["roleAliasArn"]
+        == f"arn:aws:iot:eu-west-1:123456789012:rolealias/{role_alias_name}"
+    )
+
+    assert len(client.list_role_aliases()["roleAliases"]) == 1
+
+
+def test_create_role_alias_twice(client):
+    role_alias_name = "test-role-alias"
+    create_response = client.create_role_alias(
+        roleAlias=role_alias_name,
+        roleArn="arn:aws:iam::123456789012:role/my-role",
+        credentialDurationSeconds=1234,
+    )
+
+    assert create_response["roleAlias"] == role_alias_name
+    assert (
+        create_response["roleAliasArn"]
+        == f"arn:aws:iot:eu-west-1:123456789012:rolealias/{role_alias_name}"
+    )
+
+    with pytest.raises(client.exceptions.ResourceAlreadyExistsException):
+        client.create_role_alias(
+            roleAlias=role_alias_name,
+            roleArn="arn:aws:iam::123456789012:role/my-role",
+            credentialDurationSeconds=1234,
+        )
+
+
+def test_list_role_aliases(client):
+    client.create_role_alias(
+        roleAlias="test-role-alias", roleArn="arn:aws:iam::123456789012:role/my-role"
+    )
+    client.create_role_alias(
+        roleAlias="another_role_alias",
+        roleArn="arn:aws:iam::123456789012:role/my-role",
+    )
+
+    response = client.list_role_aliases()
+
+    assert response["roleAliases"] == ["test-role-alias", "another_role_alias"]
+
+
+def test_delete_role_alias(client):
+    role_alias_name = "test-role-alias"
+
+    client.create_role_alias(
+        roleAlias=role_alias_name, roleArn="arn:aws:iam::123456789012:role/my-role"
+    )
+    assert len(client.list_role_aliases()["roleAliases"]) == 1
+
+    client.delete_role_alias(roleAlias=role_alias_name)
+    assert len(client.list_role_aliases()["roleAliases"]) == 0
+
+
+def test_delete_nonexistent_role_alias(client):
+    with pytest.raises(client.exceptions.ResourceNotFoundException):
+        client.delete_role_alias(roleAlias="test_role_alias")
+
+
+def test_describe_role_alias(client):
+    role_alias_name = "test-role-alias"
+    client.create_role_alias(
+        roleAlias=role_alias_name, roleArn="arn:aws:iam::123456789012:role/my-role"
+    )
+
+    response = client.describe_role_alias(roleAlias=role_alias_name)
+    assert response["roleAliasDescription"]["roleAlias"] == role_alias_name
+    assert (
+        response["roleAliasDescription"]["roleAliasArn"]
+        == f"arn:aws:iot:eu-west-1:123456789012:rolealias/{role_alias_name}"
+    )
+    assert (
+        response["roleAliasDescription"]["roleArn"]
+        == "arn:aws:iam::123456789012:role/my-role"
+    )
+    assert response["roleAliasDescription"]["credentialDurationSeconds"] == 3600
+    assert "owner" in response["roleAliasDescription"]
+    assert "creationDate" in response["roleAliasDescription"]
+    assert "lastModifiedDate" in response["roleAliasDescription"]
+
+
+def test_update_role_alias(client):
+    role_alias_name = "test-role-alias"
+    client.create_role_alias(
+        roleAlias=role_alias_name,
+        roleArn="arn:aws:iam::123456789012:role/my-role",
+        credentialDurationSeconds=1234,
+    )
+    client.update_role_alias(
+        roleAlias=role_alias_name,
+        roleArn="arn:aws:iam::123456789012:role/other-role",
+        credentialDurationSeconds=2345,
+    )
+    response = client.describe_role_alias(roleAlias=role_alias_name)
+
+    assert response["roleAliasDescription"]["roleAlias"] == role_alias_name
+    assert (
+        response["roleAliasDescription"]["roleAliasArn"]
+        == f"arn:aws:iot:eu-west-1:123456789012:rolealias/{role_alias_name}"
+    )
+    assert (
+        response["roleAliasDescription"]["roleArn"]
+        == "arn:aws:iam::123456789012:role/other-role"
+    )
+    assert response["roleAliasDescription"]["credentialDurationSeconds"] == 2345
diff --git a/tests/test_iot/test_iot_search.py b/tests/test_iot/test_iot_search.py
index 50c8884d2..5c0a501cf 100644
--- a/tests/test_iot/test_iot_search.py
+++ b/tests/test_iot/test_iot_search.py
@@ -1,3 +1,5 @@
+import json
+
 import boto3
 import pytest
 
@@ -31,6 +33,48 @@ def test_search_things(query_string, results):
     for thing in resp["things"]:
         del thing["connectivity"]["timestamp"]
         assert thing["connectivity"] == {"connected": True}
+        assert "thingId" in thing
+
+
+@mock_aws
+def test_search_things_include_group_names():
+    client = boto3.client("iot", region_name="ap-northeast-1")
+
+    thing_name = "test-thing-name"
+    client.create_thing(thingName=thing_name)
+
+    client.create_thing_group(thingGroupName="TestGroup1")
+    client.create_thing_group(thingGroupName="AnotherGroup")
+    client.create_thing_group(thingGroupName="GroupWithoutMembers")
+
+    client.add_thing_to_thing_group(thingName=thing_name, thingGroupName="TestGroup1")
+    client.add_thing_to_thing_group(thingName=thing_name, thingGroupName="AnotherGroup")
+
+    resp = client.search_index(queryString=f"thingName:{thing_name}")
+    assert len(resp["things"]) == 1
+    assert resp["things"][0]["thingGroupNames"] == ["TestGroup1", "AnotherGroup"]
+
+
+@mock_aws
+def test_search_things_include_named_shadow():
+    iot_client = boto3.client("iot", region_name="ap-northeast-1")
+    iotdata_client = boto3.client("iot-data", region_name="ap-northeast-1")
+    raw_payload = b'{"state": {"desired": {"led": "on"}, "reported": {"led": "off"}}}'
+
+    thing_name = "test-thing-name"
+    iot_client.create_thing(thingName=thing_name)
+    iotdata_client.update_thing_shadow(
+        thingName=thing_name, shadowName="test_shadow", payload=raw_payload
+    )
+
+    resp = iot_client.search_index(queryString=f"thingName:{thing_name}")
+
+    assert len(resp["things"]) == 1
+    shadow = json.loads(resp["things"][0]["shadow"])
+
+    assert shadow["name"]["test_shadow"]["desired"] == {"led": "on"}
+    assert shadow["name"]["test_shadow"]["reported"] == {"led": "off"}
+    assert shadow["name"]["test_shadow"]["hasDelta"]
 
 
 @mock_aws