Repository: getmoto/moto
Pull request #7569 (2024-04-05 05:14:02)

ResourceGroupsTaggingAPI: Fix Workspaces access in unsupported region
This PR fixes a bug in ResourceGroupsTaggingAPI GetResources operation when called in us-east-2.

Internally the operation enumerates through the backends on various services. Backends also check whether a given service is available in a region. In this case, if a request to ResourceGroupsTaggingAPI GetResources is made to us-east-2 (or any region where Workspaces is not supported), an unhandled exception is thrown.

The issue is fixed by checking whether a service exists in a given region before attempting backend retrieval.

```py
$ ipy
Python 3.11.0rc1 (main, Aug 12 2022, 10:02:14) [GCC 11.2.0]
Type 'copyright', 'credits' or 'license' for more information
IPython 8.16.1 -- An enhanced Interactive Python. Type '?' for help.

In [1]: import boto3
   ...: import os
   ...: from moto import mock_aws

In [2]: mock = mock_aws()

In [3]: mock.start()

In [4]: client = boto3.client('resourcegroupstaggingapi', region_name='us-east-2')

In [5]: client.get_resources()
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)

(truncated)

File ~/repo/moto-ext/moto/resourcegroupstaggingapi/responses.py:23, in ResourceGroupsTaggingAPIResponse.get_resources(self)
     20 tags_per_page = self._get_int_param("TagsPerPage", 100)
     21 resource_type_filters = self._get_param("ResourceTypeFilters", [])
---> 23 pagination_token, resource_tag_mapping_list = self.backend.get_resources(
     24     pagination_token=pagination_token,
     25     tag_filters=tag_filters,
     26     resources_per_page=resources_per_page,
     27     tags_per_page=tags_per_page,
     28     resource_type_filters=resource_type_filters,
     29 )
     31 # Format tag response
     32 response = {"ResourceTagMappingList": resource_tag_mapping_list}

File ~/repo/moto-ext/moto/resourcegroupstaggingapi/models.py:795, in ResourceGroupsTaggingAPIBackend.get_resources(self, pagination_token, resources_per_page, tags_per_page, tag_filters, resource_type_filters)
    792 try:
    793     while True:
    794         # Generator format: [{'ResourceARN': str, 'Tags': [{'Key': str, 'Value': str]}, ...]
--> 795         next_item = next(generator)
    796         resource_tags = len(next_item["Tags"])
    798         if current_resources >= resources_per_page:

File ~/repo/moto-ext/moto/resourcegroupstaggingapi/models.py:541, in ResourceGroupsTaggingAPIBackend._get_resources_generator(self, tag_filters, resource_type_filters)
    539 # Workspaces
    540 if not resource_type_filters or "workspaces" in resource_type_filters:
--> 541     for ws in self.workspaces_backend.workspaces.values():
    542         tags = format_tag_keys(ws.tags, ["Key", "Value"])
    543         if not tags or not tag_filter(
    544             tags
    545         ):  # Skip if no tags, or invalid filter

File ~/repo/moto-ext/moto/resourcegroupstaggingapi/models.py:126, in ResourceGroupsTaggingAPIBackend.workspaces_backend(self)
    124 @property
    125 def workspaces_backend(self) -> WorkSpacesBackend:
--> 126     return workspaces_backends[self.account_id][self.region_name]

File ~/repo/moto-ext/moto/core/base_backend.py:291, in AccountSpecificBackend.__getitem__(self, region_name)
    287     if region_name not in self.regions and allow_unknown_region():
    288         super().__setitem__(
    289             region_name, self.backend(region_name, account_id=self.account_id)
    290         )
--> 291 return super().__getitem__(region_name)

KeyError: 'us-east-2'
```

Related: https://github.com/getmoto/moto/pull/7493

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getmoto/moto/pull/7569?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 `87.50000%` with `1 lines` in your changes are missing coverage. Please review.
> Project coverage is 94.07%. Comparing base [(`9aef694`)](https://app.codecov.io/gh/getmoto/moto/commit/9aef6944da6a6f1b84be99cefb34f1724e6ca58f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) to head [(`9243317`)](https://app.codecov.io/gh/getmoto/moto/pull/7569?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).
> Report is 162 commits behind head on master.

| [Files](https://app.codecov.io/gh/getmoto/moto/pull/7569?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/resourcegroupstaggingapi/models.py](https://app.codecov.io/gh/getmoto/moto/pull/7569?src=pr&el=tree&filepath=moto%2Fresourcegroupstaggingapi%2Fmodels.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#diff-bW90by9yZXNvdXJjZWdyb3Vwc3RhZ2dpbmdhcGkvbW9kZWxzLnB5) | 87.50% | [1 Missing :warning: ](https://app.codecov.io/gh/getmoto/moto/pull/7569?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    #7569      +/-   ##
==========================================
- Coverage   95.88%   94.07%   -1.81%     
==========================================
  Files         843     1070     +227     
  Lines       82578    91935    +9357     
==========================================
+ Hits        79178    86486    +7308     
- Misses       3400     5449    +2049     
```

| [Flag](https://app.codecov.io/gh/getmoto/moto/pull/7569/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/7569/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `29.54% <75.00%> (-2.98%)` | :arrow_down: |
| [unittests](https://app.codecov.io/gh/getmoto/moto/pull/7569/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `94.04% <87.50%> (-1.81%)` | :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/7569?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/cognitoidp/models.py b/moto/cognitoidp/models.py
index e1dd68cc9..2b8917197 100644
--- a/moto/cognitoidp/models.py
+++ b/moto/cognitoidp/models.py
@@ -1964,7 +1964,7 @@ class CognitoIdpBackend(BaseBackend):
             if not refresh_token:
                 raise ResourceNotFoundError(refresh_token)
 
-            res = user_pool.refresh_tokens[refresh_token]
+            res = user_pool.refresh_tokens.get(refresh_token)
             if res is None:
                 raise NotAuthorizedError("Refresh Token has been revoked")
 
diff --git a/moto/iot/models.py b/moto/iot/models.py
index 7b0e2a48c..2193a960c 100644
--- a/moto/iot/models.py
+++ b/moto/iot/models.py
@@ -1796,6 +1796,9 @@ class IoTBackend(BaseBackend):
         return self.rules[rule_name].to_get_dict()
 
     def create_topic_rule(self, rule_name: str, sql: str, **kwargs: Any) -> None:
+        if not re.match("^[a-zA-Z0-9_]+$", rule_name):
+            msg = f"1 validation error detected: Value '{rule_name}' at 'ruleName' failed to satisfy constraint: Member must satisfy regular expression pattern: ^[a-zA-Z0-9_]+$"
+            raise InvalidRequestException(msg)
         if rule_name in self.rules:
             raise ResourceAlreadyExistsException(
                 "Rule with given name already exists", "", self.rules[rule_name].arn
diff --git a/moto/iotdata/models.py b/moto/iotdata/models.py
index cb4b7cce1..8f25f13f0 100644
--- a/moto/iotdata/models.py
+++ b/moto/iotdata/models.py
@@ -153,7 +153,7 @@ class FakeShadow(BaseModel):
 class IoTDataPlaneBackend(BaseBackend):
     def __init__(self, region_name: str, account_id: str):
         super().__init__(region_name, account_id)
-        self.published_payloads: List[Tuple[str, str]] = list()
+        self.published_payloads: List[Tuple[str, bytes]] = list()
 
     @property
     def iot_backend(self) -> IoTBackend:
@@ -211,7 +211,7 @@ class IoTDataPlaneBackend(BaseBackend):
         thing.thing_shadows[shadow_name] = new_shadow
         return new_shadow
 
-    def publish(self, topic: str, payload: str) -> None:
+    def publish(self, topic: str, payload: bytes) -> None:
         self.published_payloads.append((topic, payload))
 
     def list_named_shadows_for_thing(self, thing_name: str) -> List[FakeShadow]:
diff --git a/moto/resourcegroupstaggingapi/models.py b/moto/resourcegroupstaggingapi/models.py
index c8f48559a..d9baf5a40 100644
--- a/moto/resourcegroupstaggingapi/models.py
+++ b/moto/resourcegroupstaggingapi/models.py
@@ -122,8 +122,11 @@ class ResourceGroupsTaggingAPIBackend(BaseBackend):
         return dynamodb_backends[self.account_id][self.region_name]
 
     @property
-    def workspaces_backend(self) -> WorkSpacesBackend:
-        return workspaces_backends[self.account_id][self.region_name]
+    def workspaces_backend(self) -> Optional[WorkSpacesBackend]:
+        # Workspaces service has limited region availability
+        if self.region_name in workspaces_backends[self.account_id].regions:
+            return workspaces_backends[self.account_id][self.region_name]
+        return None
 
     def _get_resources_generator(
         self,
@@ -537,7 +540,9 @@ class ResourceGroupsTaggingAPIBackend(BaseBackend):
                 }
 
         # Workspaces
-        if not resource_type_filters or "workspaces" in resource_type_filters:
+        if self.workspaces_backend and (
+            not resource_type_filters or "workspaces" in resource_type_filters
+        ):
             for ws in self.workspaces_backend.workspaces.values():
                 tags = format_tag_keys(ws.tags, ["Key", "Value"])
                 if not tags or not tag_filter(
@@ -551,7 +556,9 @@ class ResourceGroupsTaggingAPIBackend(BaseBackend):
                 }
 
         # Workspace Directories
-        if not resource_type_filters or "workspaces-directory" in resource_type_filters:
+        if self.workspaces_backend and (
+            not resource_type_filters or "workspaces-directory" in resource_type_filters
+        ):
             for wd in self.workspaces_backend.workspace_directories.values():
                 tags = format_tag_keys(wd.tags, ["Key", "Value"])
                 if not tags or not tag_filter(
@@ -565,7 +572,9 @@ class ResourceGroupsTaggingAPIBackend(BaseBackend):
                 }
 
         # Workspace Images
-        if not resource_type_filters or "workspaces-image" in resource_type_filters:
+        if self.workspaces_backend and (
+            not resource_type_filters or "workspaces-image" in resource_type_filters
+        ):
             for wi in self.workspaces_backend.workspace_images.values():
                 tags = format_tag_keys(wi.tags, ["Key", "Value"])
                 if not tags or not tag_filter(
@@ -932,7 +941,7 @@ class ResourceGroupsTaggingAPIBackend(BaseBackend):
                 )
             elif arn.startswith("arn:aws:workspaces:"):
                 resource_id = arn.split("/")[-1]
-                self.workspaces_backend.create_tags(
+                self.workspaces_backend.create_tags(  # type: ignore[union-attr]
                     resource_id, TaggingService.convert_dict_to_tags_input(tags)
                 )
             elif arn.startswith("arn:aws:logs:"):
diff --git a/setup.cfg b/setup.cfg
index a7d287bc5..1594df76a 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -54,7 +54,7 @@ all =
     openapi-spec-validator>=0.5.0
     pyparsing>=3.0.7
     jsondiff>=1.1.2
-    py-partiql-parser==0.5.2
+    py-partiql-parser==0.5.4
     aws-xray-sdk!=0.96,>=0.93
     setuptools
     multipart
@@ -69,7 +69,7 @@ proxy =
     openapi-spec-validator>=0.5.0
     pyparsing>=3.0.7
     jsondiff>=1.1.2
-    py-partiql-parser==0.5.2
+    py-partiql-parser==0.5.4
     aws-xray-sdk!=0.96,>=0.93
     setuptools
     multipart
@@ -84,7 +84,7 @@ server =
     openapi-spec-validator>=0.5.0
     pyparsing>=3.0.7
     jsondiff>=1.1.2
-    py-partiql-parser==0.5.2
+    py-partiql-parser==0.5.4
     aws-xray-sdk!=0.96,>=0.93
     setuptools
     flask!=2.2.0,!=2.2.1
@@ -119,7 +119,7 @@ cloudformation =
     openapi-spec-validator>=0.5.0
     pyparsing>=3.0.7
     jsondiff>=1.1.2
-    py-partiql-parser==0.5.2
+    py-partiql-parser==0.5.4
     aws-xray-sdk!=0.96,>=0.93
     setuptools
 cloudfront =
@@ -141,10 +141,10 @@ dms =
 ds =
 dynamodb =
     docker>=3.0.0
-    py-partiql-parser==0.5.2
+    py-partiql-parser==0.5.4
 dynamodbstreams =
     docker>=3.0.0
-    py-partiql-parser==0.5.2
+    py-partiql-parser==0.5.4
 ebs =
 ec2 =
 ec2instanceconnect =
@@ -208,15 +208,15 @@ resourcegroupstaggingapi =
     openapi-spec-validator>=0.5.0
     pyparsing>=3.0.7
     jsondiff>=1.1.2
-    py-partiql-parser==0.5.2
+    py-partiql-parser==0.5.4
 route53 =
 route53resolver =
 s3 =
     PyYAML>=5.1
-    py-partiql-parser==0.5.2
+    py-partiql-parser==0.5.4
 s3crc32c =
     PyYAML>=5.1
-    py-partiql-parser==0.5.2
+    py-partiql-parser==0.5.4
     crc32c
 s3control =
 sagemaker =
@@ -273,7 +273,7 @@ disable = W,C,R,E
 enable = arguments-renamed, deprecated-module, function-redefined, redefined-outer-name, signature-differs
 
 [mypy]
-files= moto, tests/test_core, tests/test_batch_simple
+files= moto, tests/test_core, tests/test_batch_simple, tests/test_iotdata/
 exclude = moto/stepfunctions/parser
 show_column_numbers=True
 show_error_codes = True


Test patch:
diff --git a/tests/test_iot/test_iot_topic_rules.py b/tests/test_iot/test_iot_topic_rules.py
index 89a425e22..f088f9fd4 100644
--- a/tests/test_iot/test_iot_topic_rules.py
+++ b/tests/test_iot/test_iot_topic_rules.py
@@ -4,7 +4,7 @@ from botocore.exceptions import ClientError
 
 from moto import mock_aws
 
-name = "my-rule"
+name = "myrule"
 payload = {
     "sql": "SELECT * FROM 'topic/*' WHERE something > 0",
     "actions": [
@@ -19,6 +19,17 @@ payload = {
 }
 
 
+@mock_aws
+@pytest.mark.parametrize("invalid_name", ["t-r", "t.r", "t r"])
+def test_topic_rule_create_with_invalid_name(invalid_name):
+    client = boto3.client("iot", region_name="us-east-1")
+    with pytest.raises(ClientError) as exc:
+        client.create_topic_rule(ruleName=invalid_name, topicRulePayload=payload)
+    err = exc.value.response["Error"]
+    assert err["Code"] == "InvalidRequestException"
+    assert "Member must satisfy regular expression pattern" in err["Message"]
+
+
 @mock_aws
 def test_topic_rule_create():
     client = boto3.client("iot", region_name="ap-northeast-1")
@@ -41,11 +52,11 @@ def test_topic_rule_list():
     assert len(res["rules"]) == 0
 
     client.create_topic_rule(ruleName=name, topicRulePayload=payload)
-    client.create_topic_rule(ruleName="my-rule-2", topicRulePayload=payload)
+    client.create_topic_rule(ruleName=f"{name}2", topicRulePayload=payload)
 
     res = client.list_topic_rules()
     assert len(res["rules"]) == 2
-    for rule, rule_name in zip(res["rules"], [name, "my-rule-2"]):
+    for rule, rule_name in zip(res["rules"], [name, f"{name}2"]):
         assert rule["ruleName"] == rule_name
         assert rule["createdAt"] is not None
         assert rule["ruleArn"] is not None
diff --git a/tests/test_iotdata/test_iotdata.py b/tests/test_iotdata/test_iotdata.py
index 60e58c081..8f369d4d0 100644
--- a/tests/test_iotdata/test_iotdata.py
+++ b/tests/test_iotdata/test_iotdata.py
@@ -15,7 +15,7 @@ boto3_version = sys.modules["botocore"].__version__
 
 
 @mock_aws
-def test_basic():
+def test_basic() -> None:
     iot_client = boto3.client("iot", region_name="ap-northeast-1")
     client = boto3.client("iot-data", region_name="ap-northeast-1")
     name = "my-thing"
@@ -36,7 +36,7 @@ def test_basic():
 
     res = client.get_thing_shadow(thingName=name)
     payload = json.loads(res["payload"].read())
-    expected_state = b'{"desired": {"led": "on"}, "delta": {"led": "on"}}'
+    expected_state = '{"desired": {"led": "on"}, "delta": {"led": "on"}}'
     assert payload["state"] == json.loads(expected_state)
     assert "led" in payload["metadata"]["desired"]
     assert payload["version"] == 1
@@ -48,7 +48,7 @@ def test_basic():
 
 
 @mock_aws
-def test_update():
+def test_update() -> None:
     iot_client = boto3.client("iot", region_name="ap-northeast-1")
     client = boto3.client("iot-data", region_name="ap-northeast-1")
     name = "my-thing"
@@ -66,7 +66,7 @@ def test_update():
 
     res = client.get_thing_shadow(thingName=name)
     payload = json.loads(res["payload"].read())
-    expected_state = b'{"desired": {"led": "on"}, "delta": {"led": "on"}}'
+    expected_state = '{"desired": {"led": "on"}, "delta": {"led": "on"}}'
     assert payload["state"] == json.loads(expected_state)
     assert "led" in payload["metadata"]["desired"]
     assert payload["version"] == 1
@@ -84,7 +84,7 @@ def test_update():
 
     res = client.get_thing_shadow(thingName=name)
     payload = json.loads(res["payload"].read())
-    expected_state = b'{"desired": {"led": "on"}, "reported": {"led": "on"}}'
+    expected_state = '{"desired": {"led": "on"}, "reported": {"led": "on"}}'
     assert payload["state"] == json.loads(expected_state)
     assert "led" in payload["metadata"]["desired"]
     assert payload["version"] == 2
@@ -98,7 +98,7 @@ def test_update():
 
 
 @mock_aws
-def test_create_named_shadows():
+def test_create_named_shadows() -> None:
     if LooseVersion(boto3_version) < LooseVersion("1.29.0"):
         raise SkipTest("Parameter only available in newer versions")
     iot_client = boto3.client("iot", region_name="ap-northeast-1")
@@ -154,23 +154,25 @@ def test_create_named_shadows():
 
 
 @mock_aws
-def test_publish():
+def test_publish() -> None:
     region_name = "ap-northeast-1"
     client = boto3.client("iot-data", region_name=region_name)
     client.publish(topic="test/topic1", qos=1, payload=b"pl1")
     client.publish(topic="test/topic2", qos=1, payload=b"pl2")
     client.publish(topic="test/topic3", qos=1, payload=b"\xbf")
+    client.publish(topic="test/topic4", qos=1, payload="string")
 
     if not settings.TEST_SERVER_MODE:
         mock_backend = moto.iotdata.models.iotdata_backends[ACCOUNT_ID][region_name]
-        assert len(mock_backend.published_payloads) == 3
+        assert len(mock_backend.published_payloads) == 4
         assert ("test/topic1", b"pl1") in mock_backend.published_payloads
         assert ("test/topic2", b"pl2") in mock_backend.published_payloads
         assert ("test/topic3", b"\xbf") in mock_backend.published_payloads
+        assert ("test/topic4", b"string") in mock_backend.published_payloads
 
 
 @mock_aws
-def test_delete_field_from_device_shadow():
+def test_delete_field_from_device_shadow() -> None:
     test_thing_name = "TestThing"
 
     iot_raw_client = boto3.client("iot", region_name="eu-central-1")
diff --git a/tests/test_iotdata/test_server.py b/tests/test_iotdata/test_server.py
index a6a9d595c..c0085d902 100644
--- a/tests/test_iotdata/test_server.py
+++ b/tests/test_iotdata/test_server.py
@@ -11,7 +11,7 @@ Test the different server responses
 
 
 @mock_aws
-def test_iotdata_list():
+def test_iotdata_list() -> None:
     backend = server.create_backend_app("iot-data")
     test_client = backend.test_client()
 
@@ -29,7 +29,7 @@ def test_iotdata_list():
     ],
 )
 @mock_aws
-def test_publish(url_encode_topic):
+def test_publish(url_encode_topic: bool) -> None:
     backend = server.create_backend_app("iot-data")
     test_client = backend.test_client()
 
diff --git a/tests/test_resourcegroupstaggingapi/test_resourcegroupstagging_glue.py b/tests/test_resourcegroupstaggingapi/test_resourcegroupstagging_glue.py
index b6165fa97..627a2cd31 100644
--- a/tests/test_resourcegroupstaggingapi/test_resourcegroupstagging_glue.py
+++ b/tests/test_resourcegroupstaggingapi/test_resourcegroupstagging_glue.py
@@ -8,7 +8,7 @@ from moto.core import DEFAULT_ACCOUNT_ID
 
 @mock_aws
 def test_glue_jobs():
-    glue = boto3.client("glue", region_name="us-west-2")
+    glue = boto3.client("glue", region_name="us-west-1")
     tag_key = str(uuid4())[0:6]
     tag_val = str(uuid4())[0:6]
     job_name = glue.create_job(
@@ -17,9 +17,9 @@ def test_glue_jobs():
         Command={"Name": "test_command"},
         Tags={tag_key: tag_val},
     )["Name"]
-    job_arn = f"arn:aws:glue:us-west-2:{DEFAULT_ACCOUNT_ID}:job/{job_name}"
+    job_arn = f"arn:aws:glue:us-west-1:{DEFAULT_ACCOUNT_ID}:job/{job_name}"
 
-    rtapi = boto3.client("resourcegroupstaggingapi", region_name="us-west-2")
+    rtapi = boto3.client("resourcegroupstaggingapi", region_name="us-west-1")
     resources = rtapi.get_resources(ResourceTypeFilters=["glue"])[
         "ResourceTagMappingList"
     ]