Repository: getmoto/moto
Pull request #7690 (2024-05-13 11:20:24)

ResourceGroupTagging: Fix filtering for lambda functions with "lambda:function"
The resource group tagging api `get_resources` method doesn't allow for filtering for lambda functions using a `lambda:function` resource type filter, it's only checking for `lambda` - this adds support for that, in the same way it's done for other kinds of resources

First commit has the failing test:
```
        resp = rtapi.get_resources(ResourceTypeFilters=["lambda:function"])
>       assert_response(resp, [circle_arn, rectangle_arn])

tests/test_resourcegroupstaggingapi/test_resourcegroupstaggingapi.py:708: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

response = {'ResourceTagMappingList': [], 'ResponseMetadata': {'HTTPHeaders': {'date': 'Mon, 13 May 2024 11:09:28 GMT', 'server':...Hw8'}, 'HTTPStatusCode': 200, 'RequestId': 'YRtMzouuFVfnMx5K4jhUXR0Of8m1k7vdSQHKA1JcQSdRgKLvQHw8', 'RetryAttempts': 0}}
expected_arns = ['arn:aws:lambda:us-west-2:123456789012:function:lambda-tag-value-1', 'arn:aws:lambda:us-west-2:123456789012:function:lambda-tag-value-2']

    def assert_response(response, expected_arns):
        results = response.get("ResourceTagMappingList", [])
        resultArns = []
        for item in results:
            resultArns.append(item["ResourceARN"])
        for arn in resultArns:
            assert arn in expected_arns
        for arn in expected_arns:
>           assert arn in resultArns
E           AssertionError: assert 'arn:aws:lambda:us-west-2:123456789012:function:lambda-tag-value-1' in []
```


Patch:
diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md
index 137ab980c..ab6b46e57 100644
--- a/IMPLEMENTATION_COVERAGE.md
+++ b/IMPLEMENTATION_COVERAGE.md
@@ -3528,7 +3528,7 @@
 
 ## glacier
 <details>
-<summary>24% implemented</summary>
+<summary>30% implemented</summary>
 
 - [ ] abort_multipart_upload
 - [ ] abort_vault_lock
@@ -3536,12 +3536,12 @@
 - [ ] complete_multipart_upload
 - [ ] complete_vault_lock
 - [X] create_vault
-- [ ] delete_archive
+- [X] delete_archive
 - [X] delete_vault
 - [ ] delete_vault_access_policy
 - [ ] delete_vault_notifications
 - [X] describe_job
-- [ ] describe_vault
+- [X] describe_vault
 - [ ] get_data_retrieval_policy
 - [X] get_job_output
 - [ ] get_vault_access_policy
diff --git a/docs/docs/services/glacier.rst b/docs/docs/services/glacier.rst
index 44e4fcca2..a32904d9b 100644
--- a/docs/docs/services/glacier.rst
+++ b/docs/docs/services/glacier.rst
@@ -20,12 +20,12 @@ glacier
 - [ ] complete_multipart_upload
 - [ ] complete_vault_lock
 - [X] create_vault
-- [ ] delete_archive
+- [X] delete_archive
 - [X] delete_vault
 - [ ] delete_vault_access_policy
 - [ ] delete_vault_notifications
 - [X] describe_job
-- [ ] describe_vault
+- [X] describe_vault
 - [ ] get_data_retrieval_policy
 - [X] get_job_output
 - [ ] get_vault_access_policy
diff --git a/docs/docs/services/resiliencehub.rst b/docs/docs/services/resiliencehub.rst
index fc1394c2f..9b4b16da6 100644
--- a/docs/docs/services/resiliencehub.rst
+++ b/docs/docs/services/resiliencehub.rst
@@ -50,6 +50,42 @@ resiliencehub
 - [ ] list_app_assessment_compliance_drifts
 - [ ] list_app_assessment_resource_drifts
 - [X] list_app_assessments
+  
+        Moto will not actually execute any assessments, so this operation will return an empty list by default.
+        You can use a dedicated API to override this, by configuring a queue of expected results.
+
+        A request to `list_app_assessments` will take the first result from that queue, with subsequent calls with the same parameters returning the same result.
+
+        Calling `list_app_assessments` with a different set of parameters will return the second result from that queue - and so on, or an empty list of the queue is empty.
+
+        Configure this queue by making an HTTP request to `/moto-api/static/resilience-hub-assessments/response`. An example invocation looks like this:
+
+        .. sourcecode:: python
+
+            summary1 = {"appArn": "app_arn1", "appVersion": "some version", ...}
+            summary2 = {"appArn": "app_arn2", ...}
+            results = {"results": [[summary1, summary2], [summary2]], "region": "us-east-1"}
+            resp = requests.post(
+                "http://motoapi.amazonaws.com/moto-api/static/resilience-hub-assessments/response",
+                json=results,
+            )
+
+            assert resp.status_code == 201
+
+            client = boto3.client("lambda", region_name="us-east-1")
+            # First result
+            resp = client.list_app_assessments() # [summary1, summary2]
+            # Second result
+            resp = client.list_app_assessments(assessmentStatus="Pending") # [summary2]
+
+        If you're using MotoServer, make sure to make this request to where MotoServer is running:
+
+        .. sourcecode:: python
+
+            http://localhost:5000/moto-api/static/resilience-hub-assessments/response
+
+        
+
 - [ ] list_app_component_compliances
 - [ ] list_app_component_recommendations
 - [ ] list_app_input_sources
diff --git a/moto/glacier/models.py b/moto/glacier/models.py
index 38608e385..c7ed6b052 100644
--- a/moto/glacier/models.py
+++ b/moto/glacier/models.py
@@ -190,7 +190,7 @@ class GlacierBackend(BaseBackend):
         super().__init__(region_name, account_id)
         self.vaults: Dict[str, Vault] = {}
 
-    def get_vault(self, vault_name: str) -> Vault:
+    def describe_vault(self, vault_name: str) -> Vault:
         if vault_name not in self.vaults:
             raise JsonRESTError(error_type="VaultNotFound", message="Vault not found")
         return self.vaults[vault_name]
@@ -207,31 +207,35 @@ class GlacierBackend(BaseBackend):
     def initiate_job(
         self, vault_name: str, job_type: str, tier: str, archive_id: Optional[str]
     ) -> str:
-        vault = self.get_vault(vault_name)
+        vault = self.describe_vault(vault_name)
         job_id = vault.initiate_job(job_type, tier, archive_id)
         return job_id
 
     def describe_job(self, vault_name: str, archive_id: str) -> Optional[Job]:
-        vault = self.get_vault(vault_name)
+        vault = self.describe_vault(vault_name)
         return vault.describe_job(archive_id)
 
     def list_jobs(self, vault_name: str) -> List[Job]:
-        vault = self.get_vault(vault_name)
+        vault = self.describe_vault(vault_name)
         return vault.list_jobs()
 
     def get_job_output(
         self, vault_name: str, job_id: str
     ) -> Union[str, Dict[str, Any], None]:
-        vault = self.get_vault(vault_name)
+        vault = self.describe_vault(vault_name)
         if vault.job_ready(job_id):
             return vault.get_job_output(job_id)
         else:
             return None
 
+    def delete_archive(self, vault_name: str, archive_id: str) -> None:
+        vault = self.describe_vault(vault_name)
+        vault.delete_archive(archive_id)
+
     def upload_archive(
         self, vault_name: str, body: bytes, description: str
     ) -> Dict[str, Any]:
-        vault = self.get_vault(vault_name)
+        vault = self.describe_vault(vault_name)
         return vault.create_archive(body, description)
 
 
diff --git a/moto/glacier/responses.py b/moto/glacier/responses.py
index c444d9ac7..bf0ed7b64 100644
--- a/moto/glacier/responses.py
+++ b/moto/glacier/responses.py
@@ -1,5 +1,5 @@
 import json
-from typing import Any, Dict
+from typing import Any
 
 from moto.core.common_types import TYPE_RESPONSE
 from moto.core.responses import BaseResponse
@@ -12,183 +12,100 @@ class GlacierResponse(BaseResponse):
     def __init__(self) -> None:
         super().__init__(service_name="glacier")
 
+    def setup_class(
+        self, request: Any, full_url: str, headers: Any, use_raw_body: bool = False
+    ) -> None:
+        super().setup_class(request, full_url, headers, use_raw_body=True)
+
     @property
     def glacier_backend(self) -> GlacierBackend:
         return glacier_backends[self.current_account][self.region]
 
-    def all_vault_response(
-        self, request: Any, full_url: str, headers: Any
-    ) -> TYPE_RESPONSE:
-        self.setup_class(request, full_url, headers)
-        return self._all_vault_response(headers)
-
-    def _all_vault_response(self, headers: Any) -> TYPE_RESPONSE:
+    def list_vaults(self) -> TYPE_RESPONSE:
         vaults = self.glacier_backend.list_vaults()
         response = json.dumps(
             {"Marker": None, "VaultList": [vault.to_dict() for vault in vaults]}
         )
 
-        headers["content-type"] = "application/json"
+        headers = {"content-type": "application/json"}
         return 200, headers, response
 
-    def vault_response(
-        self, request: Any, full_url: str, headers: Any
-    ) -> TYPE_RESPONSE:
-        self.setup_class(request, full_url, headers)
-        return self._vault_response(request, full_url, headers)
-
-    def _vault_response(  # type: ignore[return]
-        self, request: Any, full_url: str, headers: Any
-    ) -> TYPE_RESPONSE:
-        method = request.method
-        vault_name = vault_from_glacier_url(full_url)
-
-        if method == "GET":
-            return self._vault_response_get(vault_name, headers)
-        elif method == "PUT":
-            return self._vault_response_put(vault_name, headers)
-        elif method == "DELETE":
-            return self._vault_response_delete(vault_name, headers)
-
-    def _vault_response_get(self, vault_name: str, headers: Any) -> TYPE_RESPONSE:
-        vault = self.glacier_backend.get_vault(vault_name)
-        headers["content-type"] = "application/json"
+    def describe_vault(self) -> TYPE_RESPONSE:
+        vault_name = vault_from_glacier_url(self.uri)
+        vault = self.glacier_backend.describe_vault(vault_name)
+        headers = {"content-type": "application/json"}
         return 200, headers, json.dumps(vault.to_dict())
 
-    def _vault_response_put(self, vault_name: str, headers: Any) -> TYPE_RESPONSE:
+    def create_vault(self) -> TYPE_RESPONSE:
+        vault_name = vault_from_glacier_url(self.uri)
         self.glacier_backend.create_vault(vault_name)
-        return 201, headers, ""
+        return 201, {"status": 201}, ""
 
-    def _vault_response_delete(self, vault_name: str, headers: Any) -> TYPE_RESPONSE:
+    def delete_vault(self) -> TYPE_RESPONSE:
+        vault_name = vault_from_glacier_url(self.uri)
         self.glacier_backend.delete_vault(vault_name)
-        return 204, headers, ""
-
-    def vault_archive_response(
-        self, request: Any, full_url: str, headers: Any
-    ) -> TYPE_RESPONSE:
-        self.setup_class(request, full_url, headers, use_raw_body=True)
-        return self._vault_archive_response(request, full_url, headers)
-
-    def _vault_archive_response(
-        self, request: Any, full_url: str, headers: Any
-    ) -> TYPE_RESPONSE:
-        method = request.method
-        if hasattr(request, "body"):
-            body = request.body
-        else:
-            body = request.data
-        description = ""
-        if "x-amz-archive-description" in request.headers:
-            description = request.headers["x-amz-archive-description"]
-        vault_name = full_url.split("/")[-2]
-
-        if method == "POST":
-            return self._vault_archive_response_post(
-                vault_name, body, description, headers
-            )
-        else:
-            return 400, headers, "400 Bad Request"
-
-    def _vault_archive_response_post(
-        self, vault_name: str, body: bytes, description: str, headers: Dict[str, Any]
-    ) -> TYPE_RESPONSE:
-        vault = self.glacier_backend.upload_archive(vault_name, body, description)
-        headers["x-amz-archive-id"] = vault["archive_id"]
-        headers["x-amz-sha256-tree-hash"] = vault["sha256"]
+        return 204, {"status": 204}, ""
+
+    def upload_archive(self) -> TYPE_RESPONSE:
+        description = self.headers.get("x-amz-archive-description") or ""
+        vault_name = self.parsed_url.path.split("/")[-2]
+        vault = self.glacier_backend.upload_archive(vault_name, self.body, description)
+        headers = {
+            "x-amz-archive-id": vault["archive_id"],
+            "x-amz-sha256-tree-hash": vault["sha256"],
+            "status": 201,
+        }
         return 201, headers, ""
 
-    def vault_archive_individual_response(
-        self, request: Any, full_url: str, headers: Any
-    ) -> TYPE_RESPONSE:
-        self.setup_class(request, full_url, headers)
-        return self._vault_archive_individual_response(request, full_url, headers)
-
-    def _vault_archive_individual_response(  # type: ignore[return]
-        self, request: Any, full_url: str, headers: Any
-    ) -> TYPE_RESPONSE:
-        method = request.method
-        vault_name = full_url.split("/")[-3]
-        archive_id = full_url.split("/")[-1]
-
-        if method == "DELETE":
-            vault = self.glacier_backend.get_vault(vault_name)
-            vault.delete_archive(archive_id)
-            return 204, headers, ""
-
-    def vault_jobs_response(
-        self, request: Any, full_url: str, headers: Any
-    ) -> TYPE_RESPONSE:
-        self.setup_class(request, full_url, headers)
-        return self._vault_jobs_response(request, full_url, headers)
-
-    def _vault_jobs_response(  # type: ignore[return]
-        self, request: Any, full_url: str, headers: Any
-    ) -> TYPE_RESPONSE:
-        method = request.method
-        if hasattr(request, "body"):
-            body = request.body
-        else:
-            body = request.data
-        account_id = full_url.split("/")[1]
-        vault_name = full_url.split("/")[-2]
-
-        if method == "GET":
-            jobs = self.glacier_backend.list_jobs(vault_name)
-            headers["content-type"] = "application/json"
-            return (
-                200,
-                headers,
-                json.dumps(
-                    {"JobList": [job.to_dict() for job in jobs], "Marker": None}
-                ),
-            )
-        elif method == "POST":
-            json_body = json.loads(body.decode("utf-8"))
-            job_type = json_body["Type"]
-            archive_id = None
-            if "ArchiveId" in json_body:
-                archive_id = json_body["ArchiveId"]
-            if "Tier" in json_body:
-                tier = json_body["Tier"]
-            else:
-                tier = "Standard"
-            job_id = self.glacier_backend.initiate_job(
-                vault_name, job_type, tier, archive_id
-            )
-            headers["x-amz-job-id"] = job_id
-            headers["Location"] = f"/{account_id}/vaults/{vault_name}/jobs/{job_id}"
-            return 202, headers, ""
-
-    def vault_jobs_individual_response(
-        self, request: Any, full_url: str, headers: Any
-    ) -> TYPE_RESPONSE:
-        self.setup_class(request, full_url, headers)
-        return self._vault_jobs_individual_response(full_url, headers)
-
-    def _vault_jobs_individual_response(
-        self, full_url: str, headers: Any
-    ) -> TYPE_RESPONSE:
-        vault_name = full_url.split("/")[-3]
-        archive_id = full_url.split("/")[-1]
+    def delete_archive(self) -> TYPE_RESPONSE:
+        vault_name = self.parsed_url.path.split("/")[-3]
+        archive_id = self.parsed_url.path.split("/")[-1]
 
-        job = self.glacier_backend.describe_job(vault_name, archive_id)
-        return 200, headers, json.dumps(job.to_dict())  # type: ignore
+        self.glacier_backend.delete_archive(vault_name, archive_id)
+        return 204, {"status": 204}, ""
 
-    def vault_jobs_output_response(
-        self, request: Any, full_url: str, headers: Any
-    ) -> TYPE_RESPONSE:
-        self.setup_class(request, full_url, headers)
-        return self._vault_jobs_output_response(full_url, headers)
+    def list_jobs(self) -> TYPE_RESPONSE:
+        vault_name = self.parsed_url.path.split("/")[-2]
+        jobs = self.glacier_backend.list_jobs(vault_name)
+        headers = {"content-type": "application/json"}
+        response = json.dumps(
+            {"JobList": [job.to_dict() for job in jobs], "Marker": None}
+        )
+        return 200, headers, response
+
+    def initiate_job(self) -> TYPE_RESPONSE:
+        account_id = self.uri.split("/")[1]
+        vault_name = self.parsed_url.path.split("/")[-2]
+        json_body = json.loads(self.body.decode("utf-8"))
+        job_type = json_body["Type"]
+        archive_id = json_body.get("ArchiveId")
+        tier = json_body.get("Tier") or "Standard"
+        job_id = self.glacier_backend.initiate_job(
+            vault_name, job_type, tier, archive_id
+        )
+        headers = {
+            "x-amz-job-id": job_id,
+            "Location": f"/{account_id}/vaults/{vault_name}/jobs/{job_id}",
+            "status": 202,
+        }
+        return 202, headers, ""
+
+    def describe_job(self) -> str:
+        vault_name = self.parsed_url.path.split("/")[-3]
+        archive_id = self.parsed_url.path.split("/")[-1]
+
+        job = self.glacier_backend.describe_job(vault_name, archive_id)
+        return json.dumps(job.to_dict())  # type: ignore
 
-    def _vault_jobs_output_response(self, full_url: str, headers: Any) -> TYPE_RESPONSE:
-        vault_name = full_url.split("/")[-4]
-        job_id = full_url.split("/")[-2]
+    def get_job_output(self) -> TYPE_RESPONSE:
+        vault_name = self.parsed_url.path.split("/")[-4]
+        job_id = self.parsed_url.path.split("/")[-2]
         output = self.glacier_backend.get_job_output(vault_name, job_id)
         if output is None:
-            return 404, headers, "404 Not Found"
+            return 404, {"status": 404}, "404 Not Found"
         if isinstance(output, dict):
-            headers["content-type"] = "application/json"
+            headers = {"content-type": "application/json"}
             return 200, headers, json.dumps(output)
         else:
-            headers["content-type"] = "application/octet-stream"
+            headers = {"content-type": "application/octet-stream"}
             return 200, headers, output
diff --git a/moto/glacier/urls.py b/moto/glacier/urls.py
index 64a47a8f3..bb7dc5d98 100644
--- a/moto/glacier/urls.py
+++ b/moto/glacier/urls.py
@@ -3,25 +3,11 @@ from .responses import GlacierResponse
 url_bases = [r"https?://glacier\.(.+)\.amazonaws.com"]
 
 url_paths = {
-    "{0}/(?P<account_number>.+)/vaults$": GlacierResponse.method_dispatch(
-        GlacierResponse.all_vault_response
-    ),
-    "{0}/(?P<account_number>.+)/vaults/(?P<vault_name>[^/]+)$": GlacierResponse.method_dispatch(
-        GlacierResponse.vault_response
-    ),
-    "{0}/(?P<account_number>.+)/vaults/(?P<vault_name>.+)/archives$": GlacierResponse.method_dispatch(
-        GlacierResponse.vault_archive_response
-    ),
-    "{0}/(?P<account_number>.+)/vaults/(?P<vault_name>.+)/archives/(?P<archive_id>.+)$": GlacierResponse.method_dispatch(
-        GlacierResponse.vault_archive_individual_response
-    ),
-    "{0}/(?P<account_number>.+)/vaults/(?P<vault_name>.+)/jobs$": GlacierResponse.method_dispatch(
-        GlacierResponse.vault_jobs_response
-    ),
-    "{0}/(?P<account_number>.+)/vaults/(?P<vault_name>.+)/jobs/(?P<job_id>[^/.]+)$": GlacierResponse.method_dispatch(
-        GlacierResponse.vault_jobs_individual_response
-    ),
-    "{0}/(?P<account_number>.+)/vaults/(?P<vault_name>.+)/jobs/(?P<job_id>.+)/output$": GlacierResponse.method_dispatch(
-        GlacierResponse.vault_jobs_output_response
-    ),
+    "{0}/(?P<account_number>.+)/vaults$": GlacierResponse.dispatch,
+    "{0}/(?P<account_number>.+)/vaults/(?P<vault_name>[^/]+)$": GlacierResponse.dispatch,
+    "{0}/(?P<account_number>.+)/vaults/(?P<vault_name>.+)/archives$": GlacierResponse.dispatch,
+    "{0}/(?P<account_number>.+)/vaults/(?P<vault_name>.+)/archives/(?P<archive_id>.+)$": GlacierResponse.dispatch,
+    "{0}/(?P<account_number>.+)/vaults/(?P<vault_name>.+)/jobs$": GlacierResponse.dispatch,
+    "{0}/(?P<account_number>.+)/vaults/(?P<vault_name>.+)/jobs/(?P<job_id>[^/.]+)$": GlacierResponse.dispatch,
+    "{0}/(?P<account_number>.+)/vaults/(?P<vault_name>.+)/jobs/(?P<job_id>.+)/output$": GlacierResponse.dispatch,
 }
diff --git a/moto/moto_api/_internal/models.py b/moto/moto_api/_internal/models.py
index 1b00d3d5f..70dc10f2a 100644
--- a/moto/moto_api/_internal/models.py
+++ b/moto/moto_api/_internal/models.py
@@ -62,6 +62,14 @@ class MotoAPIBackend(BaseBackend):
         backend = lambda_simple_backends[account_id][region]
         backend.lambda_simple_results_queue.append(result)
 
+    def set_resilience_result(
+        self, result: List[Dict[str, Any]], account_id: str, region: str
+    ) -> None:
+        from moto.resiliencehub.models import resiliencehub_backends
+
+        backend = resiliencehub_backends[account_id][region]
+        backend.app_assessments_queue.append(result)
+
     def set_sagemaker_result(
         self,
         body: str,
diff --git a/moto/moto_api/_internal/responses.py b/moto/moto_api/_internal/responses.py
index 1aceb29e4..f89899b44 100644
--- a/moto/moto_api/_internal/responses.py
+++ b/moto/moto_api/_internal/responses.py
@@ -197,6 +197,24 @@ class MotoAPIResponse(BaseResponse):
             )
         return 201, {}, ""
 
+    def set_resilience_result(
+        self,
+        request: Any,
+        full_url: str,  # pylint: disable=unused-argument
+        headers: Any,
+    ) -> TYPE_RESPONSE:
+        from .models import moto_api_backend
+
+        body = self._get_body(headers, request)
+        account_id = body.get("account_id", DEFAULT_ACCOUNT_ID)
+        region = body.get("region", "us-east-1")
+
+        for result in body.get("results", []):
+            moto_api_backend.set_resilience_result(
+                result=result, account_id=account_id, region=region
+            )
+        return 201, {}, ""
+
     def set_sagemaker_result(
         self,
         request: Any,
diff --git a/moto/moto_api/_internal/urls.py b/moto/moto_api/_internal/urls.py
index 278ed7249..2195f2ee5 100644
--- a/moto/moto_api/_internal/urls.py
+++ b/moto/moto_api/_internal/urls.py
@@ -18,6 +18,7 @@ url_paths = {
     "{0}/moto-api/static/ce/cost-and-usage-results": response_instance.set_ce_cost_usage_result,
     "{0}/moto-api/static/inspector2/findings-results": response_instance.set_inspector2_findings_result,
     "{0}/moto-api/static/lambda-simple/response": response_instance.set_lambda_simple_result,
+    "{0}/moto-api/static/resilience-hub-assessments/response": response_instance.set_resilience_result,
     "{0}/moto-api/static/sagemaker/endpoint-results": response_instance.set_sagemaker_result,
     "{0}/moto-api/static/rds-data/statement-results": response_instance.set_rds_data_result,
     "{0}/moto-api/state-manager/get-transition": response_instance.get_transition,
diff --git a/moto/resiliencehub/models.py b/moto/resiliencehub/models.py
index 8a5cf568d..d6ec83ab8 100644
--- a/moto/resiliencehub/models.py
+++ b/moto/resiliencehub/models.py
@@ -186,6 +186,9 @@ class ResilienceHubBackend(BaseBackend):
         self.policies: Dict[str, Policy] = dict()
         self.tagger = TaggingService()
 
+        self.app_assessments_queue: List[List[Dict[str, Any]]] = []
+        self.app_assessments_results: Dict[str, List[Dict[str, Any]]] = {}
+
     def create_app(
         self,
         assessment_schedule: str,
@@ -251,7 +254,49 @@ class ResilienceHubBackend(BaseBackend):
             app_summaries.reverse()
         return app_summaries
 
-    def list_app_assessments(self) -> List[str]:
+    def list_app_assessments(self, request_identifier: str) -> List[Dict[str, Any]]:
+        """
+        Moto will not actually execute any assessments, so this operation will return an empty list by default.
+        You can use a dedicated API to override this, by configuring a queue of expected results.
+
+        A request to `list_app_assessments` will take the first result from that queue, with subsequent calls with the same parameters returning the same result.
+
+        Calling `list_app_assessments` with a different set of parameters will return the second result from that queue - and so on, or an empty list of the queue is empty.
+
+        Configure this queue by making an HTTP request to `/moto-api/static/resilience-hub-assessments/response`. An example invocation looks like this:
+
+        .. sourcecode:: python
+
+            summary1 = {"appArn": "app_arn1", "appVersion": "some version", ...}
+            summary2 = {"appArn": "app_arn2", ...}
+            results = {"results": [[summary1, summary2], [summary2]], "region": "us-east-1"}
+            resp = requests.post(
+                "http://motoapi.amazonaws.com/moto-api/static/resilience-hub-assessments/response",
+                json=results,
+            )
+
+            assert resp.status_code == 201
+
+            client = boto3.client("lambda", region_name="us-east-1")
+            # First result
+            resp = client.list_app_assessments() # [summary1, summary2]
+            # Second result
+            resp = client.list_app_assessments(assessmentStatus="Pending") # [summary2]
+
+        If you're using MotoServer, make sure to make this request to where MotoServer is running:
+
+        .. sourcecode:: python
+
+            http://localhost:5000/moto-api/static/resilience-hub-assessments/response
+
+        """
+        if request_identifier in self.app_assessments_results:
+            return self.app_assessments_results[request_identifier]
+        if self.app_assessments_queue:
+            self.app_assessments_results[request_identifier] = (
+                self.app_assessments_queue.pop(0)
+            )
+            return self.app_assessments_results[request_identifier]
         return []
 
     def describe_app(self, app_arn: str) -> App:
diff --git a/moto/resiliencehub/responses.py b/moto/resiliencehub/responses.py
index 7a8f3b903..651e9451c 100644
--- a/moto/resiliencehub/responses.py
+++ b/moto/resiliencehub/responses.py
@@ -97,7 +97,23 @@ class ResilienceHubResponse(BaseResponse):
         )
 
     def list_app_assessments(self) -> str:
-        summaries = self.resiliencehub_backend.list_app_assessments()
+        supported_params = [
+            "appArn",
+            "assessmentName",
+            "assessmentStatus",
+            "complianceStatus",
+            "invoker",
+            "maxResults",
+            "nextToken",
+            "reverseOrder",
+        ]
+        provided_params = [p for p in self.querystring.keys() if p in supported_params]
+        request_identifier = json.dumps(
+            {key: self.querystring[key] for key in sorted(provided_params)}
+        )
+        summaries = self.resiliencehub_backend.list_app_assessments(
+            request_identifier=request_identifier,
+        )
         return json.dumps(dict(assessmentSummaries=summaries))
 
     def describe_app(self) -> str:
diff --git a/moto/resourcegroupstaggingapi/models.py b/moto/resourcegroupstaggingapi/models.py
index 657a74cdb..07c08d087 100644
--- a/moto/resourcegroupstaggingapi/models.py
+++ b/moto/resourcegroupstaggingapi/models.py
@@ -613,7 +613,11 @@ class ResourceGroupsTaggingAPIBackend(BaseBackend):
         # VPC VPN Connection
 
         # Lambda Instance
-        if not resource_type_filters or "lambda" in resource_type_filters:
+        if (
+            not resource_type_filters
+            or "lambda" in resource_type_filters
+            or "lambda:function" in resource_type_filters
+        ):
             for f in self.lambda_backend.list_functions():
                 tags = format_tags(f.tags)
                 if not tags or not tag_filter(tags):
diff --git a/moto/s3/models.py b/moto/s3/models.py
index 97e1bf50b..1d23ca294 100644
--- a/moto/s3/models.py
+++ b/moto/s3/models.py
@@ -2291,6 +2291,11 @@ class S3Backend(BaseBackend, CloudWatchMetricProvider):
     ) -> FakeKey:
         if key is None:
             raise MissingKey(key=key_name)
+
+        # get bucket for eventbridge notification
+        # we can assume that the key has its bucket
+        bucket = self.get_bucket(key.bucket_name)  # type: ignore
+
         tags_input = self.tagger.convert_dict_to_tags_input(tags)
         # Validation custom to S3
         if tags:
@@ -2304,6 +2309,12 @@ class S3Backend(BaseBackend, CloudWatchMetricProvider):
             raise InvalidTagError(errmsg)
         self.tagger.delete_all_tags_for_resource(key.arn)
         self.tagger.tag_resource(key.arn, tags_input)
+        notifications.send_event(
+            self.account_id,
+            notifications.S3NotificationEvent.OBJECT_TAGGING_PUT_EVENT,
+            bucket,
+            key,
+        )
         return key
 
     def get_bucket_tagging(self, bucket_name: str) -> Dict[str, List[Dict[str, str]]]:
diff --git a/moto/ssm/resources/regions.json b/moto/ssm/resources/regions.json
index 4c2dea712..eb3272571 100644
--- a/moto/ssm/resources/regions.json
+++ b/moto/ssm/resources/regions.json
@@ -11687,6 +11687,15 @@
                   "Value": "HTTPS"
                 }
               },
+              "opensearchserverless": {
+                "Value": "opensearchserverless",
+                "endpoint": {
+                  "Value": "aoss.ap-south-1.amazonaws.com"
+                },
+                "protocols": {
+                  "Value": "HTTPS"
+                }
+              },
               "opsworks": {
                 "Value": "opsworks",
                 "endpoint": {
@@ -24813,6 +24822,15 @@
                   "Value": "HTTPS"
                 }
               },
+              "license-manager-user-subscriptions": {
+                "Value": "license-manager-user-subscriptions",
+                "endpoint": {
+                  "Value": "license-manager-user-subscriptions.ca-west-1.amazonaws.com"
+                },
+                "protocols": {
+                  "Value": "HTTPS"
+                }
+              },
               "logs": {
                 "Value": "logs",
                 "endpoint": {
@@ -25053,6 +25071,15 @@
                   "Value": "HTTPS"
                 }
               },
+              "shield": {
+                "Value": "shield",
+                "endpoint": {
+                  "Value": "shield.us-east-1.amazonaws.com"
+                },
+                "protocols": {
+                  "Value": "HTTPS"
+                }
+              },
               "sns": {
                 "Value": "sns",
                 "endpoint": {
@@ -43738,6 +43765,15 @@
                   "Value": "HTTPS"
                 }
               },
+              "opensearchserverless": {
+                "Value": "opensearchserverless",
+                "endpoint": {
+                  "Value": "aoss.eu-west-3.amazonaws.com"
+                },
+                "protocols": {
+                  "Value": "HTTPS"
+                }
+              },
               "opsworks": {
                 "Value": "opsworks",
                 "endpoint": {
diff --git a/moto/ssm/resources/services.json b/moto/ssm/resources/services.json
index 458a76cda..4700949fb 100644
--- a/moto/ssm/resources/services.json
+++ b/moto/ssm/resources/services.json
@@ -38292,6 +38292,15 @@
                   "Value": "HTTPS"
                 }
               },
+              "ca-west-1": {
+                "Value": "ca-west-1",
+                "endpoint": {
+                  "Value": "license-manager-user-subscriptions.ca-west-1.amazonaws.com"
+                },
+                "protocols": {
+                  "Value": "HTTPS"
+                }
+              },
               "eu-central-1": {
                 "Value": "eu-central-1",
                 "endpoint": {
@@ -44915,6 +44924,15 @@
                   "Value": "HTTPS"
                 }
               },
+              "ap-south-1": {
+                "Value": "ap-south-1",
+                "endpoint": {
+                  "Value": "aoss.ap-south-1.amazonaws.com"
+                },
+                "protocols": {
+                  "Value": "HTTPS"
+                }
+              },
               "ap-southeast-1": {
                 "Value": "ap-southeast-1",
                 "endpoint": {
@@ -44951,6 +44969,15 @@
                   "Value": "HTTPS"
                 }
               },
+              "eu-west-3": {
+                "Value": "eu-west-3",
+                "endpoint": {
+                  "Value": "aoss.eu-west-3.amazonaws.com"
+                },
+                "protocols": {
+                  "Value": "HTTPS"
+                }
+              },
               "us-east-1": {
                 "Value": "us-east-1",
                 "endpoint": {
@@ -58475,6 +58502,15 @@
                   "Value": "HTTPS"
                 }
               },
+              "ca-west-1": {
+                "Value": "ca-west-1",
+                "endpoint": {
+                  "Value": "shield.us-east-1.amazonaws.com"
+                },
+                "protocols": {
+                  "Value": "HTTPS"
+                }
+              },
               "eu-central-1": {
                 "Value": "eu-central-1",
                 "endpoint": {


Test patch:
diff --git a/tests/test_glacier/test_glacier_jobs.py b/tests/test_glacier/test_glacier_jobs.py
index 740451040..46a089bc8 100644
--- a/tests/test_glacier/test_glacier_jobs.py
+++ b/tests/test_glacier/test_glacier_jobs.py
@@ -31,7 +31,7 @@ def test_initiate_job():
 
 
 @mock_aws
-def test_describe_job_boto3():
+def test_describe_job():
     client = boto3.client("glacier", region_name="us-west-2")
     client.create_vault(vaultName="myname")
 
@@ -102,7 +102,7 @@ def test_list_jobs():
 
 
 @mock_aws
-def test_get_job_output_boto3():
+def test_get_job_output():
     client = boto3.client("glacier", region_name="us-west-2")
     client.create_vault(vaultName="myname")
 
diff --git a/tests/test_resiliencehub/test_resiliencehub.py b/tests/test_resiliencehub/test_resiliencehub.py
index 5e470757c..fcf936261 100644
--- a/tests/test_resiliencehub/test_resiliencehub.py
+++ b/tests/test_resiliencehub/test_resiliencehub.py
@@ -2,9 +2,10 @@ from uuid import uuid4
 
 import boto3
 import pytest
+import requests
 from botocore.exceptions import ClientError
 
-from moto import mock_aws
+from moto import mock_aws, settings
 
 # See our Development Tips on writing tests for hints on how to write good tests:
 # http://docs.getmoto.org/en/latest/docs/contributing/development_tips/tests.html
@@ -209,6 +210,44 @@ def test_list_apps():
 def test_list_app_assessments():
     client = boto3.client("resiliencehub", region_name="ap-southeast-1")
     assert client.list_app_assessments()["assessmentSummaries"] == []
+    assert client.list_app_assessments(appArn="asdf")["assessmentSummaries"] == []
+
+    # Setup
+    base_url = (
+        settings.test_server_mode_endpoint()
+        if settings.TEST_SERVER_MODE
+        else "http://motoapi.amazonaws.com"
+    )
+    # Configure results for the first two requests
+    summary1 = {"appArn": "app_arn1", "appVersion": "some version"}
+    summary2 = {"appArn": "app_arn2", "assessmentName": "assessment 2"}
+    results = {
+        "results": [[summary1, summary2], [summary2]],
+        "region": "ap-southeast-1",
+    }
+    resp = requests.post(
+        f"{base_url}/moto-api/static/resilience-hub-assessments/response",
+        json=results,
+    )
+    assert resp.status_code == 201
+
+    # First response
+    assert client.list_app_assessments()["assessmentSummaries"] == [summary1, summary2]
+
+    # Stays the same on subsequent invocations
+    assert client.list_app_assessments()["assessmentSummaries"] == [summary1, summary2]
+
+    # Second response with different args
+    assert client.list_app_assessments(appArn="asdf")["assessmentSummaries"] == [
+        summary2
+    ]
+
+    # Third response returns an empty response, because nothing else is configured
+    assert client.list_app_assessments(appArn="diff")["assessmentSummaries"] == []
+
+    # First response in different region also returns nothing, because we only configured this in ap-southeast
+    us_client = boto3.client("resiliencehub", region_name="us-east-1")
+    assert us_client.list_app_assessments()["assessmentSummaries"] == []
 
 
 @mock_aws
diff --git a/tests/test_resourcegroupstaggingapi/test_resourcegroupstaggingapi.py b/tests/test_resourcegroupstaggingapi/test_resourcegroupstaggingapi.py
index fe6ad6147..c1bbb2b80 100644
--- a/tests/test_resourcegroupstaggingapi/test_resourcegroupstaggingapi.py
+++ b/tests/test_resourcegroupstaggingapi/test_resourcegroupstaggingapi.py
@@ -704,6 +704,9 @@ def test_get_resources_lambda():
     resp = rtapi.get_resources(ResourceTypeFilters=["lambda"])
     assert_response(resp, [circle_arn, rectangle_arn])
 
+    resp = rtapi.get_resources(ResourceTypeFilters=["lambda:function"])
+    assert_response(resp, [circle_arn, rectangle_arn])
+
     resp = rtapi.get_resources(TagFilters=[{"Key": "Color", "Values": ["green"]}])
     assert_response(resp, [circle_arn, rectangle_arn])
 
diff --git a/tests/test_s3/test_s3_eventbridge_integration.py b/tests/test_s3/test_s3_eventbridge_integration.py
index 8bd9dc815..8c1e3772f 100644
--- a/tests/test_s3/test_s3_eventbridge_integration.py
+++ b/tests/test_s3/test_s3_eventbridge_integration.py
@@ -81,7 +81,7 @@ def test_put_object_notification_ObjectCreated_PUT():
     s3_client.put_object(Bucket=bucket_name, Key="keyname", Body="bodyofnewobject")
 
     events = _get_send_events()
-    assert len(events) == 1
+    assert len(events) == 2
     event_message = json.loads(events[0]["message"])
     assert event_message["detail-type"] == "Object Created"
     assert event_message["source"] == "aws.s3"
@@ -138,7 +138,7 @@ def test_copy_object_notification():
     )
 
     events = _get_send_events()
-    assert len(events) == 2  # [PutObject event, CopyObject event]
+    assert len(events) == 3  # [PutObject, ObjectTagging, CopyObject]
     event_message = json.loads(events[-1]["message"])
     assert event_message["detail-type"] == "Object Created"
     assert event_message["source"] == "aws.s3"
@@ -188,7 +188,7 @@ def test_complete_multipart_upload_notification():
     )
 
     events = _get_send_events()
-    assert len(events) == 2  # [PutObject event, CompleteMultipartUpload event]
+    assert len(events) == 3  # [PutObject, ObjectTagging, CompleteMultipartUpload]
     event_message = json.loads(events[-1]["message"])
     assert event_message["detail-type"] == "Object Created"
     assert event_message["source"] == "aws.s3"
@@ -212,8 +212,8 @@ def test_delete_object_notification():
     s3_client.delete_object(Bucket=bucket_name, Key="keyname")
 
     events = _get_send_events()
-    assert len(events) == 2
-    event_message = json.loads(events[1]["message"])
+    assert len(events) == 3  # [PutObject, ObjectTagging, DeleteObect]
+    event_message = json.loads(events[-1]["message"])
     assert event_message["detail-type"] == "Object Deleted"
     assert event_message["source"] == "aws.s3"
     assert event_message["account"] == ACCOUNT_ID
@@ -235,18 +235,60 @@ def test_restore_key_notifications():
 
     events = _get_send_events()
     event_names = [json.loads(e["message"])["detail"]["reason"] for e in events]
-    assert event_names == ["ObjectCreated", "ObjectRestore"]
+    assert event_names == ["ObjectCreated", "ObjectTagging", "ObjectRestore"]
 
     # Finish the Object Restoration - restore Completes immediately by default
     key.load()
 
     events = _get_send_events()
     event_names = [json.loads(e["message"])["detail"]["reason"] for e in events]
-    assert event_names == ["ObjectCreated", "ObjectRestore", "ObjectRestore"]
+    assert event_names == [
+        "ObjectCreated",
+        "ObjectTagging",
+        "ObjectRestore",
+        "ObjectRestore",
+    ]
 
     # Sanity check - loading the Key does not mean the Restore-event is fired every time
     key.load()
 
     events = _get_send_events()
     event_names = [json.loads(e["message"])["detail"]["reason"] for e in events]
-    assert event_names == ["ObjectCreated", "ObjectRestore", "ObjectRestore"]
+    assert event_names == [
+        "ObjectCreated",
+        "ObjectTagging",
+        "ObjectRestore",
+        "ObjectRestore",
+    ]
+
+
+@mock_aws
+def test_put_object_tagging_notification():
+    resource_names = _seteup_bucket_notification_eventbridge()
+    bucket_name = resource_names["bucket_name"]
+    s3_client = boto3.client("s3", region_name=REGION_NAME)
+
+    # Put Object
+    s3_client.put_object(Bucket=bucket_name, Key="keyname", Body="bodyofnewobject")
+
+    # Put Object Tagging
+    s3_client.put_object_tagging(
+        Bucket=bucket_name,
+        Key="keyname",
+        Tagging={
+            "TagSet": [
+                {"Key": "item1", "Value": "foo"},
+                {"Key": "item2", "Value": "bar"},
+            ]
+        },
+    )
+
+    events = _get_send_events()
+    assert len(events) == 3
+    event_message = json.loads(events[2]["message"])
+    assert event_message["detail-type"] == "Object Tags Added"
+    assert event_message["source"] == "aws.s3"
+    assert event_message["account"] == ACCOUNT_ID
+    assert event_message["region"] == REGION_NAME
+    assert event_message["detail"]["bucket"]["name"] == bucket_name
+    assert event_message["detail"]["reason"] == "ObjectTagging"