Repository: getmoto/moto
Pull request #8119 (2024-09-11 19:16:51)

Apigateway Account APIs
- Implement get_account and update_account for apigateway
- Added rootResourceId to the RestAPI class and its to_dict function so that it will be returned as it was missing ([create_rest_api](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/apigateway/client/create_rest_api.html), [get_rest_api](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/apigateway/client/get_rest_api.html))

mattheidelbaugh: Some notes/questions:
- It seems as though we don't get the boto validation for patchOperations.  Validation for this doesn't seem to be implemented for any other resources, so I didn't do it here.  Can certainly add that if desired.  Could easily be done in a utility function and applied to all functions which take patchOperations
- For some reason I can't discern, MyPy is unhappy with my Account class which takes no arguments, so I've ignored those errors.

Patch:
diff --git a/.github/workflows/data-update_config-managed-rules.yml b/.github/workflows/data-update_config-managed-rules.yml
index fd27f0e54..16504d8e1 100644
--- a/.github/workflows/data-update_config-managed-rules.yml
+++ b/.github/workflows/data-update_config-managed-rules.yml
@@ -37,7 +37,7 @@ jobs:
         cd scripts
         ./pull_down_aws_managed_rules.py
     - name: Create PR
-      uses: peter-evans/create-pull-request@v6
+      uses: peter-evans/create-pull-request@v7
       with:
         branch: 'chore-update-config-managed-rules-${{ github.run_number }}'
         title: 'chore: update Config Managed Rules'
diff --git a/.github/workflows/data-update_ec2-instance-offerings.yml b/.github/workflows/data-update_ec2-instance-offerings.yml
index 2cdca875f..8655b6208 100644
--- a/.github/workflows/data-update_ec2-instance-offerings.yml
+++ b/.github/workflows/data-update_ec2-instance-offerings.yml
@@ -42,7 +42,7 @@ jobs:
         pip install boto3
         scripts/ec2_get_instance_type_offerings.py
     - name: Create PR
-      uses: peter-evans/create-pull-request@v6
+      uses: peter-evans/create-pull-request@v7
       with:
         branch: 'chore-update-ec2-offerings-${{ github.run_number }}'
         title: 'chore: update EC2 Instance Offerings'
diff --git a/.github/workflows/data-update_ec2-instance-types.yml b/.github/workflows/data-update_ec2-instance-types.yml
index de1c06481..64c3b9d37 100644
--- a/.github/workflows/data-update_ec2-instance-types.yml
+++ b/.github/workflows/data-update_ec2-instance-types.yml
@@ -42,7 +42,7 @@ jobs:
         pip install boto3
         scripts/get_instance_info.py
     - name: Create PR
-      uses: peter-evans/create-pull-request@v6
+      uses: peter-evans/create-pull-request@v7
       with:
         branch: 'chore-update-ec2-types-${{ github.run_number }}'
         title: 'chore: update EC2 Instance Types'
diff --git a/.github/workflows/data-update_iam-managed-policies.yml b/.github/workflows/data-update_iam-managed-policies.yml
index 87b78764f..fcfd65ed8 100644
--- a/.github/workflows/data-update_iam-managed-policies.yml
+++ b/.github/workflows/data-update_iam-managed-policies.yml
@@ -42,7 +42,7 @@ jobs:
         pip install -r requirements-dev.txt
         make aws_managed_policies
     - name: Create PR
-      uses: peter-evans/create-pull-request@v6
+      uses: peter-evans/create-pull-request@v7
       with:
         branch: 'chore-update-iam-managed-policies-${{ github.run_number }}'
         title: 'chore: update IAM Managed Policies'
diff --git a/.github/workflows/data-update_ssm-default-amis.yml b/.github/workflows/data-update_ssm-default-amis.yml
index 30ed7193c..f22b38467 100644
--- a/.github/workflows/data-update_ssm-default-amis.yml
+++ b/.github/workflows/data-update_ssm-default-amis.yml
@@ -43,7 +43,7 @@ jobs:
         pip install moto[all]
         python scripts/ssm_get_default_amis.py
     - name: Create PR
-      uses: peter-evans/create-pull-request@v6
+      uses: peter-evans/create-pull-request@v7
       with:
         branch: 'chore-update-ssm-amis-${{ github.run_number }}'
         title: "chore: update SSM Instance AMI's"
diff --git a/.github/workflows/data-update_ssm-default-parameters.yml b/.github/workflows/data-update_ssm-default-parameters.yml
index 8bf4f6f42..1a1d398d6 100644
--- a/.github/workflows/data-update_ssm-default-parameters.yml
+++ b/.github/workflows/data-update_ssm-default-parameters.yml
@@ -43,7 +43,7 @@ jobs:
         pip install moto[all]
         python scripts/ssm_get_default_params.py
     - name: Create PR
-      uses: peter-evans/create-pull-request@v6
+      uses: peter-evans/create-pull-request@v7
       with:
         branch: 'chore-update-ssm-params-${{ github.run_number }}'
         title: "chore: update SSM Instance parameters"
diff --git a/.github/workflows/data-update_ssm-optimized-amis.yml b/.github/workflows/data-update_ssm-optimized-amis.yml
index d15d3d168..96a9600e7 100644
--- a/.github/workflows/data-update_ssm-optimized-amis.yml
+++ b/.github/workflows/data-update_ssm-optimized-amis.yml
@@ -43,7 +43,7 @@ jobs:
         pip install moto[all]
         python scripts/ssm_get_optimized_amis.py
     - name: Create PR
-      uses: peter-evans/create-pull-request@v6
+      uses: peter-evans/create-pull-request@v7
       with:
         branch: 'chore-update-ssm-amis-${{ github.run_number }}'
         title: "chore: update SSM Instance AMI's"
diff --git a/moto/apigateway/models.py b/moto/apigateway/models.py
index 9ca56026a..08d89884b 100644
--- a/moto/apigateway/models.py
+++ b/moto/apigateway/models.py
@@ -71,6 +71,7 @@ from .exceptions import (
 from .utils import create_id, to_path
 
 STAGE_URL = "https://{api_id}.execute-api.{region_name}.amazonaws.com/{stage_name}"
+PATCH_OPERATIONS = ["add", "remove", "replace", "move", "copy", "test"]
 
 
 class Deployment(CloudFormationModel):
@@ -1020,6 +1021,7 @@ class RestAPI(CloudFormationModel):
     PROP_POLICY = "policy"
     PROP_DISABLE_EXECUTE_API_ENDPOINT = "disableExecuteApiEndpoint"
     PROP_MINIMUM_COMPRESSION_SIZE = "minimumCompressionSize"
+    PROP_ROOT_RESOURCE_ID = "rootResourceId"
 
     # operations
     OPERATION_ADD = "add"
@@ -1064,6 +1066,7 @@ class RestAPI(CloudFormationModel):
         self.models: Dict[str, Model] = {}
         self.request_validators: Dict[str, RequestValidator] = {}
         self.default = self.add_child("/")  # Add default child
+        self.root_resource_id = self.default.id
 
     def __repr__(self) -> str:
         return str(self.id)
@@ -1082,6 +1085,7 @@ class RestAPI(CloudFormationModel):
             self.PROP_POLICY: self.policy,
             self.PROP_DISABLE_EXECUTE_API_ENDPOINT: self.disableExecuteApiEndpoint,
             self.PROP_MINIMUM_COMPRESSION_SIZE: self.minimum_compression_size,
+            self.PROP_ROOT_RESOURCE_ID: self.root_resource_id,
         }
 
     def apply_patch_operations(self, patch_operations: List[Dict[str, Any]]) -> None:
@@ -1529,6 +1533,54 @@ class GatewayResponse(BaseModel):
         return dct
 
 
+class Account(BaseModel):
+    def __init__(self) -> None:
+        self.cloudwatch_role_arn: Optional[str] = None
+        self.throttle_settings: Dict[str, Any] = {
+            "burstLimit": 5000,
+            "rateLimit": 10000.0,
+        }
+        self.features: Optional[List[str]] = None
+        self.api_key_version: str = "1"
+
+    def apply_patch_operations(
+        self, patch_operations: List[Dict[str, Any]]
+    ) -> "Account":
+        for op in patch_operations:
+            if "/cloudwatchRoleArn" in op["path"]:
+                self.cloudwatch_role_arn = op["value"]
+            elif "/features" in op["path"]:
+                if op["op"] == "add":
+                    if self.features is None:
+                        self.features = [op["value"]]
+                    else:
+                        self.features.append(op["value"])
+                elif op["op"] == "remove":
+                    if op["value"] == "UsagePlans":
+                        raise BadRequestException(
+                            "Usage Plans cannot be disabled once enabled"
+                        )
+                    if self.features is not None:
+                        self.features.remove(op["value"])
+                else:
+                    raise NotImplementedError(
+                        f'Patch operation "{op["op"]}" for "/features" not implemented'
+                    )
+            else:
+                raise NotImplementedError(
+                    f'Patch operation "{op["op"]}" for "{op["path"]}" not implemented'
+                )
+        return self
+
+    def to_json(self) -> Dict[str, Any]:
+        return {
+            "cloudwatchRoleArn": self.cloudwatch_role_arn,
+            "throttleSettings": self.throttle_settings,
+            "features": self.features,
+            "apiKeyVersion": self.api_key_version,
+        }
+
+
 class APIGatewayBackend(BaseBackend):
     """
     API Gateway mock.
@@ -1558,6 +1610,7 @@ class APIGatewayBackend(BaseBackend):
 
     def __init__(self, region_name: str, account_id: str):
         super().__init__(region_name, account_id)
+        self.account: Account = Account()
         self.apis: Dict[str, RestAPI] = {}
         self.keys: Dict[str, ApiKey] = {}
         self.usage_plans: Dict[str, UsagePlan] = {}
@@ -2485,5 +2538,12 @@ class APIGatewayBackend(BaseBackend):
         api = self.get_rest_api(rest_api_id)
         api.delete_gateway_response(response_type)
 
+    def update_account(self, patch_operations: List[Dict[str, Any]]) -> Account:
+        account = self.account.apply_patch_operations(patch_operations)
+        return account
+
+    def get_account(self) -> Account:
+        return self.account
+
 
 apigateway_backends = BackendDict(APIGatewayBackend, "apigateway")
diff --git a/moto/apigateway/responses.py b/moto/apigateway/responses.py
index 127128419..8ffa618b9 100644
--- a/moto/apigateway/responses.py
+++ b/moto/apigateway/responses.py
@@ -851,3 +851,12 @@ class APIGatewayResponse(BaseResponse):
             rest_api_id=rest_api_id, response_type=response_type
         )
         return 202, {}, json.dumps(dict())
+
+    def update_account(self) -> str:
+        patch_operations = self._get_param("patchOperations")
+        account = self.backend.update_account(patch_operations)
+        return json.dumps(account.to_json())
+
+    def get_account(self) -> str:
+        account = self.backend.get_account()
+        return json.dumps(account.to_json())
diff --git a/moto/apigateway/urls.py b/moto/apigateway/urls.py
index 9bcd317af..fbdabf6fd 100644
--- a/moto/apigateway/urls.py
+++ b/moto/apigateway/urls.py
@@ -41,6 +41,7 @@ url_paths = {
     "{0}/restapis/(?P<api_id>[^/]+)/gatewayresponses/(?P<response_type>[^/]+)/?$": APIGatewayResponse.dispatch,
     "{0}/vpclinks$": APIGatewayResponse.dispatch,
     "{0}/vpclinks/(?P<vpclink_id>[^/]+)": APIGatewayResponse.dispatch,
+    "{0}/account$": APIGatewayResponse.dispatch,
 }
 
 # Also manages the APIGatewayV2
diff --git a/moto/appsync/exceptions.py b/moto/appsync/exceptions.py
index 7e1069e3c..d3ffd2a97 100644
--- a/moto/appsync/exceptions.py
+++ b/moto/appsync/exceptions.py
@@ -23,6 +23,17 @@ class GraphQLSchemaException(AppSyncExceptions):
         self.description = json.dumps({"message": self.message})
 
 
+class GraphqlAPICacheNotFound(AppSyncExceptions):
+    code = 404
+
+    def __init__(self, op: str):
+        super().__init__(
+            "NotFoundException",
+            f"Unable to {op} the cache as it doesn't exist, please create the cache first.",
+        )
+        self.description = json.dumps({"message": self.message})
+
+
 class BadRequestException(AppSyncExceptions):
     def __init__(self, message: str):
         super().__init__("BadRequestException", message)
diff --git a/moto/appsync/models.py b/moto/appsync/models.py
index f0e0345f6..c2dd460a9 100644
--- a/moto/appsync/models.py
+++ b/moto/appsync/models.py
@@ -10,7 +10,12 @@ from moto.moto_api._internal import mock_random
 from moto.utilities.tagging_service import TaggingService
 from moto.utilities.utils import get_partition
 
-from .exceptions import BadRequestException, GraphqlAPINotFound, GraphQLSchemaException
+from .exceptions import (
+    BadRequestException,
+    GraphqlAPICacheNotFound,
+    GraphqlAPINotFound,
+    GraphQLSchemaException,
+)
 
 # AWS custom scalars and directives
 # https://github.com/dotansimha/graphql-code-generator/discussions/4311#discussioncomment-2921796
@@ -132,6 +137,49 @@ class GraphqlAPIKey(BaseModel):
         }
 
 
+class APICache(BaseModel):
+    def __init__(
+        self,
+        ttl: int,
+        api_caching_behavior: str,
+        type_: str,
+        transit_encryption_enabled: Optional[bool] = None,
+        at_rest_encryption_enabled: Optional[bool] = None,
+        health_metrics_config: Optional[str] = None,
+    ):
+        self.ttl = ttl
+        self.api_caching_behavior = api_caching_behavior
+        self.type = type_
+        self.transit_encryption_enabled = transit_encryption_enabled or False
+        self.at_rest_encryption_enabled = at_rest_encryption_enabled or False
+        self.health_metrics_config = health_metrics_config or "DISABLED"
+        self.status = "AVAILABLE"
+
+    def update(
+        self,
+        ttl: int,
+        api_caching_behavior: str,
+        type: str,
+        health_metrics_config: Optional[str] = None,
+    ) -> None:
+        self.ttl = ttl
+        self.api_caching_behavior = api_caching_behavior
+        self.type = type
+        if health_metrics_config is not None:
+            self.health_metrics_config = health_metrics_config
+
+    def to_json(self) -> Dict[str, Any]:
+        return {
+            "ttl": self.ttl,
+            "transitEncryptionEnabled": self.transit_encryption_enabled,
+            "atRestEncryptionEnabled": self.at_rest_encryption_enabled,
+            "apiCachingBehavior": self.api_caching_behavior,
+            "type": self.type,
+            "healthMetricsConfig": self.health_metrics_config,
+            "status": self.status,
+        }
+
+
 class GraphqlAPI(BaseModel):
     def __init__(
         self,
@@ -162,6 +210,8 @@ class GraphqlAPI(BaseModel):
 
         self.api_keys: Dict[str, GraphqlAPIKey] = dict()
 
+        self.api_cache: Optional[APICache] = None
+
     def update(
         self,
         name: str,
@@ -223,6 +273,38 @@ class GraphqlAPI(BaseModel):
         graphql_type["format"] = type_format  # type: ignore[index]
         return graphql_type
 
+    def create_api_cache(
+        self,
+        ttl: int,
+        api_caching_behavior: str,
+        type: str,
+        transit_encryption_enabled: Optional[bool] = None,
+        at_rest_encryption_enabled: Optional[bool] = None,
+        health_metrics_config: Optional[str] = None,
+    ) -> APICache:
+        self.api_cache = APICache(
+            ttl,
+            api_caching_behavior,
+            type,
+            transit_encryption_enabled,
+            at_rest_encryption_enabled,
+            health_metrics_config,
+        )
+        return self.api_cache
+
+    def update_api_cache(
+        self,
+        ttl: int,
+        api_caching_behavior: str,
+        type: str,
+        health_metrics_config: Optional[str] = None,
+    ) -> APICache:
+        self.api_cache.update(ttl, api_caching_behavior, type, health_metrics_config)  # type: ignore[union-attr]
+        return self.api_cache  # type: ignore[return-value]
+
+    def delete_api_cache(self) -> None:
+        self.api_cache = None
+
     def to_json(self) -> Dict[str, Any]:
         return {
             "name": self.name,
@@ -374,5 +456,71 @@ class AppSyncBackend(BaseBackend):
     def get_type(self, api_id: str, type_name: str, type_format: str) -> Any:
         return self.graphql_apis[api_id].get_type(type_name, type_format)
 
+    def get_api_cache(self, api_id: str) -> APICache:
+        if api_id not in self.graphql_apis:
+            raise GraphqlAPINotFound(api_id)
+        api_cache = self.graphql_apis[api_id].api_cache
+        if api_cache is None:
+            raise GraphqlAPICacheNotFound("get")
+        return api_cache
+
+    def delete_api_cache(self, api_id: str) -> None:
+        if api_id not in self.graphql_apis:
+            raise GraphqlAPINotFound(api_id)
+        if self.graphql_apis[api_id].api_cache is None:
+            raise GraphqlAPICacheNotFound("delete")
+        self.graphql_apis[api_id].delete_api_cache()
+        return
+
+    def create_api_cache(
+        self,
+        api_id: str,
+        ttl: int,
+        api_caching_behavior: str,
+        type: str,
+        transit_encryption_enabled: Optional[bool] = None,
+        at_rest_encryption_enabled: Optional[bool] = None,
+        health_metrics_config: Optional[str] = None,
+    ) -> APICache:
+        if api_id not in self.graphql_apis:
+            raise GraphqlAPINotFound(api_id)
+        graphql_api = self.graphql_apis[api_id]
+        if graphql_api.api_cache is not None:
+            raise BadRequestException(message="The API has already enabled caching.")
+        api_cache = graphql_api.create_api_cache(
+            ttl,
+            api_caching_behavior,
+            type,
+            transit_encryption_enabled,
+            at_rest_encryption_enabled,
+            health_metrics_config,
+        )
+        return api_cache
+
+    def update_api_cache(
+        self,
+        api_id: str,
+        ttl: int,
+        api_caching_behavior: str,
+        type: str,
+        health_metrics_config: Optional[str] = None,
+    ) -> APICache:
+        if api_id not in self.graphql_apis:
+            raise GraphqlAPINotFound(api_id)
+        graphql_api = self.graphql_apis[api_id]
+        if graphql_api.api_cache is None:
+            raise GraphqlAPICacheNotFound("update")
+        api_cache = graphql_api.update_api_cache(
+            ttl, api_caching_behavior, type, health_metrics_config
+        )
+        return api_cache
+
+    def flush_api_cache(self, api_id: str) -> None:
+        if api_id not in self.graphql_apis:
+            raise GraphqlAPINotFound(api_id)
+        if self.graphql_apis[api_id].api_cache is None:
+            raise GraphqlAPICacheNotFound("flush")
+        return
+
 
 appsync_backends = BackendDict(AppSyncBackend, "appsync")
diff --git a/moto/appsync/responses.py b/moto/appsync/responses.py
index 570472f78..c2096000e 100644
--- a/moto/appsync/responses.py
+++ b/moto/appsync/responses.py
@@ -192,3 +192,60 @@ class AppSyncResponse(BaseResponse):
             format_=format_, include_directives=include_directives
         )
         return schema
+
+    def get_api_cache(self) -> str:
+        api_id = self.path.split("/")[-2]
+        api_cache = self.appsync_backend.get_api_cache(
+            api_id=api_id,
+        )
+        return json.dumps(dict(apiCache=api_cache.to_json()))
+
+    def delete_api_cache(self) -> str:
+        api_id = self.path.split("/")[-2]
+        self.appsync_backend.delete_api_cache(
+            api_id=api_id,
+        )
+        return "{}"
+
+    def create_api_cache(self) -> str:
+        params = json.loads(self.body)
+        api_id = self.path.split("/")[-2]
+        ttl = params.get("ttl")
+        transit_encryption_enabled = params.get("transitEncryptionEnabled")
+        at_rest_encryption_enabled = params.get("atRestEncryptionEnabled")
+        api_caching_behavior = params.get("apiCachingBehavior")
+        type = params.get("type")
+        health_metrics_config = params.get("healthMetricsConfig")
+        api_cache = self.appsync_backend.create_api_cache(
+            api_id=api_id,
+            ttl=ttl,
+            transit_encryption_enabled=transit_encryption_enabled,
+            at_rest_encryption_enabled=at_rest_encryption_enabled,
+            api_caching_behavior=api_caching_behavior,
+            type=type,
+            health_metrics_config=health_metrics_config,
+        )
+        return json.dumps(dict(apiCache=api_cache.to_json()))
+
+    def update_api_cache(self) -> str:
+        api_id = self.path.split("/")[-3]
+        params = json.loads(self.body)
+        ttl = params.get("ttl")
+        api_caching_behavior = params.get("apiCachingBehavior")
+        type = params.get("type")
+        health_metrics_config = params.get("healthMetricsConfig")
+        api_cache = self.appsync_backend.update_api_cache(
+            api_id=api_id,
+            ttl=ttl,
+            api_caching_behavior=api_caching_behavior,
+            type=type,
+            health_metrics_config=health_metrics_config,
+        )
+        return json.dumps(dict(apiCache=api_cache.to_json()))
+
+    def flush_api_cache(self) -> str:
+        api_id = self.path.split("/")[-2]
+        self.appsync_backend.flush_api_cache(
+            api_id=api_id,
+        )
+        return "{}"
diff --git a/moto/appsync/urls.py b/moto/appsync/urls.py
index 5dfe9ff55..b1d43d7a9 100644
--- a/moto/appsync/urls.py
+++ b/moto/appsync/urls.py
@@ -17,4 +17,7 @@ url_paths = {
     "{0}/v1/tags/(?P<resource_arn>.+)$": AppSyncResponse.dispatch,
     "{0}/v1/tags/(?P<resource_arn_pt1>.+)/(?P<resource_arn_pt2>.+)$": AppSyncResponse.dispatch,
     "{0}/v1/apis/(?P<api_id>[^/]+)/types/(?P<type_name>.+)$": AppSyncResponse.dispatch,
+    "{0}/v1/apis/(?P<apiId>.*)/ApiCaches$": AppSyncResponse.dispatch,
+    "{0}/v1/apis/(?P<apiId>.*)/ApiCaches/update$": AppSyncResponse.dispatch,
+    "{0}/v1/apis/(?P<apiId>.*)/FlushCache$": AppSyncResponse.dispatch,
 }
diff --git a/moto/backend_index.py b/moto/backend_index.py
index 027edb94b..a965b21fb 100644
--- a/moto/backend_index.py
+++ b/moto/backend_index.py
@@ -212,5 +212,6 @@ backend_url_patterns = [
     ("transfer", re.compile("https?://transfer\\.(.+)\\.amazonaws\\.com")),
     ("wafv2", re.compile("https?://wafv2\\.(.+)\\.amazonaws.com")),
     ("workspaces", re.compile("https?://workspaces\\.(.+)\\.amazonaws\\.com")),
+    ("workspacesweb", re.compile("https?://workspaces-web\\.(.+)\\.amazonaws\\.com")),
     ("xray", re.compile("https?://xray\\.(.+)\\.amazonaws.com")),
 ]
diff --git a/moto/backends.py b/moto/backends.py
index 15d8242ae..0ca6319f7 100644
--- a/moto/backends.py
+++ b/moto/backends.py
@@ -147,6 +147,7 @@ if TYPE_CHECKING:
     from moto.transfer.models import TransferBackend
     from moto.wafv2.models import WAFV2Backend
     from moto.workspaces.models import WorkSpacesBackend
+    from moto.workspacesweb.models import WorkSpacesWebBackend
     from moto.xray.models import XRayBackend
 
 
@@ -325,6 +326,7 @@ SERVICE_NAMES = Union[
     "Literal['transfer']",
     "Literal['wafv2']",
     "Literal['workspaces']",
+    "Literal['workspaces-web']",
     "Literal['xray']",
 ]
 
@@ -728,6 +730,10 @@ def get_backend(name: "Literal['wafv2']") -> "BackendDict[WAFV2Backend]": ...
 @overload
 def get_backend(name: "Literal['workspaces']") -> "BackendDict[WorkSpacesBackend]": ...
 @overload
+def get_backend(
+    name: "Literal['workspaces-web']",
+) -> "BackendDict[WorkSpacesWebBackend]": ...
+@overload
 def get_backend(name: "Literal['xray']") -> "BackendDict[XRayBackend]": ...
 
 
diff --git a/moto/dynamodb/exceptions.py b/moto/dynamodb/exceptions.py
index ae7c94f62..4d4d327e0 100644
--- a/moto/dynamodb/exceptions.py
+++ b/moto/dynamodb/exceptions.py
@@ -305,11 +305,11 @@ class DuplicateUpdateExpression(InvalidUpdateExpression):
         )
 
 
-class TooManyAddClauses(InvalidUpdateExpression):
-    msg = 'The "ADD" section can only be used once in an update expression;'
-
-    def __init__(self) -> None:
-        super().__init__(self.msg)
+class TooManyClauses(InvalidUpdateExpression):
+    def __init__(self, _type: str) -> None:
+        super().__init__(
+            f'The "{_type}" section can only be used once in an update expression;'
+        )
 
 
 class ResourceNotFoundException(JsonRESTError):
diff --git a/moto/dynamodb/parsing/ast_nodes.py b/moto/dynamodb/parsing/ast_nodes.py
index 95875d90d..28985bf1a 100644
--- a/moto/dynamodb/parsing/ast_nodes.py
+++ b/moto/dynamodb/parsing/ast_nodes.py
@@ -8,7 +8,7 @@ from moto.dynamodb.models import DynamoType
 from ..exceptions import (
     DuplicateUpdateExpression,
     MockValidationException,
-    TooManyAddClauses,
+    TooManyClauses,
 )
 
 
@@ -29,9 +29,10 @@ class Node(metaclass=abc.ABCMeta):
 
     def validate(self, limit_set_actions: bool = False) -> None:
         if self.type == "UpdateExpression":
-            nr_of_clauses = len(self.find_clauses([UpdateExpressionAddClause]))
-            if nr_of_clauses > 1:
-                raise TooManyAddClauses()
+            if len(self.find_clauses([UpdateExpressionAddClause])) > 1:
+                raise TooManyClauses("ADD")
+            if len(self.find_clauses([UpdateExpressionRemoveClause])) > 1:
+                raise TooManyClauses("REMOVE")
             set_actions = self.find_clauses([UpdateExpressionSetAction])
             # set_attributes = ["attr", "map.attr", attr.list[2], ..]
             set_attributes = [s.children[0].to_str() for s in set_actions]
@@ -176,8 +177,11 @@ class UpdateExpressionRemoveAction(UpdateExpressionClause):
 
     def __lt__(self, other):
         self_value = self._get_value()
-
-        return self_value < other._get_value()
+        other_value = other._get_value()
+        if isinstance(self_value, int) and isinstance(other_value, int):
+            return self_value < other_value
+        else:
+            return str(self_value) < str(other_value)
 
 
 class UpdateExpressionAddActions(UpdateExpressionClause):
diff --git a/moto/iam/models.py b/moto/iam/models.py
index 172e55aa8..3e06a6cfb 100644
--- a/moto/iam/models.py
+++ b/moto/iam/models.py
@@ -223,6 +223,7 @@ class SAMLProvider(BaseModel):
         saml_metadata_document: Optional[str] = None,
     ):
         self.account_id = account_id
+        self.create_date = utcnow()
         self.region_name = region_name
         self.name = name
         self.saml_metadata_document = saml_metadata_document
@@ -231,6 +232,10 @@ class SAMLProvider(BaseModel):
     def arn(self) -> str:
         return f"arn:{get_partition(self.region_name)}:iam::{self.account_id}:saml-provider/{self.name}"
 
+    @property
+    def created_iso_8601(self) -> str:
+        return iso_8601_datetime_without_milliseconds(self.create_date)
+
 
 class OpenIDConnectProvider(BaseModel):
     def __init__(
diff --git a/moto/iam/responses.py b/moto/iam/responses.py
index cb20c45fd..1b2be7c1f 100644
--- a/moto/iam/responses.py
+++ b/moto/iam/responses.py
@@ -1,3 +1,5 @@
+from xml.sax.saxutils import escape
+
 from moto.core.responses import BaseResponse
 
 from .models import IAMBackend, User, iam_backends
@@ -858,7 +860,10 @@ class IamResponse(BaseResponse):
 
     def create_saml_provider(self) -> str:
         saml_provider_name = self._get_param("Name")
-        saml_metadata_document = self._get_param("SAMLMetadataDocument")
+        _saml_metadata_document = self._get_param("SAMLMetadataDocument")
+        saml_metadata_document = escape(
+            _saml_metadata_document, entities={"'": "&apos;", '"': "&quot;"}
+        )
         saml_provider = self.backend.create_saml_provider(
             saml_provider_name, saml_metadata_document
         )
@@ -868,7 +873,10 @@ class IamResponse(BaseResponse):
 
     def update_saml_provider(self) -> str:
         saml_provider_arn = self._get_param("SAMLProviderArn")
-        saml_metadata_document = self._get_param("SAMLMetadataDocument")
+        _saml_metadata_document = self._get_param("SAMLMetadataDocument")
+        saml_metadata_document = escape(
+            _saml_metadata_document, entities={"'": "&apos;", '"': "&quot;"}
+        )
         saml_provider = self.backend.update_saml_provider(
             saml_provider_arn, saml_metadata_document
         )
@@ -2481,7 +2489,7 @@ LIST_SAML_PROVIDERS_TEMPLATE = """<ListSAMLProvidersResponse xmlns="https://iam.
 
 GET_SAML_PROVIDER_TEMPLATE = """<GetSAMLProviderResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
 <GetSAMLProviderResult>
-  <CreateDate>2012-05-09T16:27:11Z</CreateDate>
+  <CreateDate>{{ saml_provider.created_iso_8601 }}</CreateDate>
   <ValidUntil>2015-12-31T21:59:59Z</ValidUntil>
   <SAMLMetadataDocument>{{ saml_provider.saml_metadata_document }}</SAMLMetadataDocument>
 </GetSAMLProviderResult>
diff --git a/moto/resourcegroupstaggingapi/models.py b/moto/resourcegroupstaggingapi/models.py
index 5c4432e4f..f704b8609 100644
--- a/moto/resourcegroupstaggingapi/models.py
+++ b/moto/resourcegroupstaggingapi/models.py
@@ -28,6 +28,7 @@ from moto.ssm.models import SimpleSystemManagerBackend, ssm_backends
 from moto.utilities.tagging_service import TaggingService
 from moto.utilities.utils import get_partition
 from moto.workspaces.models import WorkSpacesBackend, workspaces_backends
+from moto.workspacesweb.models import WorkSpacesWebBackend, workspacesweb_backends
 
 # Left: EC2 ElastiCache RDS ELB CloudFront Lambda EMR Glacier Kinesis Redshift Route53
 # StorageGateway DynamoDB MachineLearning ACM DirectConnect DirectoryService CloudHSM
@@ -135,6 +136,13 @@ class ResourceGroupsTaggingAPIBackend(BaseBackend):
             return workspaces_backends[self.account_id][self.region_name]
         return None
 
+    @property
+    def workspacesweb_backends(self) -> Optional[WorkSpacesWebBackend]:
+        # Workspaces service has limited region availability
+        if self.region_name in workspaces_backends[self.account_id].regions:
+            return workspacesweb_backends[self.account_id][self.region_name]
+        return None
+
     @property
     def sagemaker_backend(self) -> SageMakerModelBackend:
         return sagemaker_backends[self.account_id][self.region_name]
@@ -626,6 +634,19 @@ class ResourceGroupsTaggingAPIBackend(BaseBackend):
                     "Tags": tags,
                 }
 
+        # Workspaces Web
+        if self.workspacesweb_backends and (
+            not resource_type_filters or "workspaces-web" in resource_type_filters
+        ):
+            for portal in self.workspacesweb_backends.portals.values():
+                tags = format_tag_keys(portal.tags, ["Key", "Value"])  # type: ignore
+                if not tags or not tag_filter(tags):
+                    continue
+                yield {
+                    "ResourceARN": f"arn:{get_partition(self.region_name)}:workspaces-web:{self.region_name}:{self.account_id}:portal/{portal.portal_id}",
+                    "Tags": tags,
+                }
+
         # VPC
         if (
             not resource_type_filters
@@ -1028,6 +1049,13 @@ class ResourceGroupsTaggingAPIBackend(BaseBackend):
                 self.rds_backend.add_tags_to_resource(
                     arn, TaggingService.convert_dict_to_tags_input(tags)
                 )
+            elif arn.startswith(
+                f"arn:{get_partition(self.region_name)}:workspaces-web:"
+            ):
+                resource_id = arn.split("/")[-1]
+                self.workspacesweb_backends.create_tags(  # type: ignore[union-attr]
+                    resource_id, TaggingService.convert_dict_to_tags_input(tags)
+                )
             elif arn.startswith(f"arn:{get_partition(self.region_name)}:workspaces:"):
                 resource_id = arn.split("/")[-1]
                 self.workspaces_backend.create_tags(  # type: ignore[union-attr]
diff --git a/moto/s3/responses.py b/moto/s3/responses.py
index 967382100..eb79a3228 100644
--- a/moto/s3/responses.py
+++ b/moto/s3/responses.py
@@ -1694,12 +1694,19 @@ class S3Response(BaseResponse):
         storage_class = self.headers.get("x-amz-storage-class", "STANDARD")
         encryption = self.headers.get("x-amz-server-side-encryption")
         kms_key_id = self.headers.get("x-amz-server-side-encryption-aws-kms-key-id")
+        if_none_match = self.headers.get("If-None-Match")
         bucket_key_enabled = self.headers.get(
             "x-amz-server-side-encryption-bucket-key-enabled"
         )
         if bucket_key_enabled is not None:
             bucket_key_enabled = str(bucket_key_enabled).lower()
 
+        if (
+            if_none_match == "*"
+            and self.backend.get_object(self.bucket_name, key_name) is not None
+        ):
+            raise PreconditionFailed("If-None-Match")
+
         checksum_algorithm, checksum_value = self._get_checksum(response_headers)
 
         bucket = self.backend.get_bucket(self.bucket_name)
diff --git a/moto/workspacesweb/__init__.py b/moto/workspacesweb/__init__.py
new file mode 100644
index 000000000..9192321e7
--- /dev/null
+++ b/moto/workspacesweb/__init__.py
@@ -0,0 +1,1 @@
+from .models import workspacesweb_backends  # noqa: F401
diff --git a/moto/workspacesweb/exceptions.py b/moto/workspacesweb/exceptions.py
new file mode 100644
index 000000000..718d0982e
--- /dev/null
+++ b/moto/workspacesweb/exceptions.py
@@ -0,0 +1,1 @@
+"""Exceptions raised by the workspacesweb service."""
diff --git a/moto/workspacesweb/models.py b/moto/workspacesweb/models.py
new file mode 100644
index 000000000..2cd4612d4
--- /dev/null
+++ b/moto/workspacesweb/models.py
@@ -0,0 +1,290 @@
+"""WorkSpacesWebBackend class with methods for supported APIs."""
+
+import datetime
+import uuid
+from typing import Any, Dict, List, Optional, Tuple
+
+from moto.core.base_backend import BackendDict, BaseBackend
+from moto.core.common_models import BaseModel
+from moto.utilities.utils import get_partition
+
+
+class FakeNetworkSettings(BaseModel):
+    def __init__(
+        self,
+        security_group_ids: List[str],
+        subnet_ids: List[str],
+        tags: Dict[str, str],
+        vpc_id: str,
+        region_name: str,
+        account_id: str,
+    ):
+        self.network_settings_id = str(uuid.uuid4())
+        self.arn = self.arn_formatter(self.network_settings_id, account_id, region_name)
+        self.security_group_ids = security_group_ids
+        self.subnet_ids = subnet_ids
+        self.tags = tags
+        self.vpc_id = vpc_id
+        self.associated_portal_arns: List[str] = []
+
+    def arn_formatter(self, _id: str, account_id: str, region_name: str) -> str:
+        return f"arn:{get_partition(region_name)}:workspaces-web:{region_name}:{account_id}:network-settings/{_id}"
+
+    def to_dict(self) -> Dict[str, Any]:
+        return {
+            "associatedPortalArns": self.associated_portal_arns,
+            "networkSettingsArn": self.arn,
+            "securityGroupIds": self.security_group_ids,
+            "subnetIds": self.subnet_ids,
+            "Tags": self.tags,
+            "vpcId": self.vpc_id,
+        }
+
+
+class FakeBrowserSettings(BaseModel):
+    def __init__(
+        self,
+        additional_encryption_context: Any,
+        browser_policy: str,
+        client_token: str,
+        customer_managed_key: str,
+        tags: Dict[str, str],
+        region_name: str,
+        account_id: str,
+    ):
+        self.browser_settings_id = str(uuid.uuid4())
+        self.arn = self.arn_formatter(self.browser_settings_id, account_id, region_name)
+        self.additional_encryption_context = additional_encryption_context
+        self.browser_policy = browser_policy
+        self.client_token = client_token
+        self.customer_managed_key = customer_managed_key
+        self.tags = tags
+        self.associated_portal_arns: List[str] = []
+
+    def arn_formatter(self, _id: str, account_id: str, region_name: str) -> str:
+        return f"arn:{get_partition(region_name)}:workspaces-web:{region_name}:{account_id}:browser-settings/{_id}"
+
+    def to_dict(self) -> Dict[str, Any]:
+        return {
+            "associatedPortalArns": self.associated_portal_arns,
+            "browserSettingsArn": self.arn,
+            "additionalEncryptionContext": self.additional_encryption_context,
+            "browserPolicy": self.browser_policy,
+            "customerManagedKey": self.customer_managed_key,
+            "tags": self.tags,
+        }
+
+
+class FakePortal(BaseModel):
+    def __init__(
+        self,
+        additional_encryption_context: Any,
+        authentication_type: str,
+        client_token: str,
+        customer_managed_key: str,
+        display_name: str,
+        instance_type: str,
+        max_concurrent_sessions: str,
+        tags: Dict[str, str],
+        region_name: str,
+        account_id: str,
+    ):
+        self.portal_id = str(uuid.uuid4())
+        self.arn = self.arn_formatter(self.portal_id, account_id, region_name)
+        self.additional_encryption_context = additional_encryption_context
+        self.authentication_type = authentication_type
+        self.client_token = client_token
+        self.customer_managed_key = customer_managed_key
+        self.display_name = display_name
+        self.instance_type = instance_type
+        self.max_concurrent_sessions = max_concurrent_sessions
+        self.tags = tags
+        self.portal_endpoint = f"{self.portal_id}.portal.aws"
+        self.browser_type = "Chrome"
+        self.creation_time = datetime.datetime.now().isoformat()
+        self.status = "CREATED"
+        self.renderer_type = "AppStream"
+        self.status_reason = "TestStatusReason"
+        self.browser_settings_arn: Optional[str] = None
+        self.network_settings_arn: Optional[str] = None
+        self.trust_store_arn: Optional[str] = None
+        self.ip_access_settings_arn: Optional[str] = None
+        self.user_access_logging_settings_arn: Optional[str] = None
+        self.user_settings_arn: Optional[str] = None
+
+    def arn_formatter(self, _id: str, account_id: str, region_name: str) -> str:
+        return f"arn:{get_partition(region_name)}:workspaces-web:{region_name}:{account_id}:portal/{_id}"
+
+    def to_dict(self) -> Dict[str, Any]:
+        return {
+            "additionalEncryptionContext": self.additional_encryption_context,
+            "authenticationType": self.authentication_type,
+            "browserSettingsArn": self.browser_settings_arn,
+            "browserType": self.browser_type,
+            "creationDate": self.creation_time,
+            "customerManagedKey": self.customer_managed_key,
+            "displayName": self.display_name,
+            "instanceType": self.instance_type,
+            "ipAccessSettingsArn": self.ip_access_settings_arn,
+            "maxConcurrentSessions": self.max_concurrent_sessions,
+            "networkSettingsArn": self.network_settings_arn,
+            "portalArn": self.arn,
+            "portalEndpoint": self.portal_endpoint,
+            "portalStatus": self.status,
+            "rendererType": self.renderer_type,
+            "statusReason": self.status_reason,
+            "trustStoreArn": self.trust_store_arn,
+            "userAccessLoggingSettingsArn": self.user_access_logging_settings_arn,
+            "userSettingsArn": self.user_settings_arn,
+            "tags": self.tags,
+        }
+
+
+class WorkSpacesWebBackend(BaseBackend):
+    """Implementation of WorkSpacesWeb APIs."""
+
+    def __init__(self, region_name: str, account_id: str):
+        super().__init__(region_name, account_id)
+        self.network_settings: Dict[str, FakeNetworkSettings] = {}
+        self.browser_settings: Dict[str, FakeBrowserSettings] = {}
+        self.portals: Dict[str, FakePortal] = {}
+
+    def create_network_settings(
+        self,
+        security_group_ids: List[str],
+        subnet_ids: List[str],
+        tags: Dict[str, str],
+        vpc_id: str,
+    ) -> str:
+        network_settings_object = FakeNetworkSettings(
+            security_group_ids,
+            subnet_ids,
+            tags,
+            vpc_id,
+            self.region_name,
+            self.account_id,
+        )
+        self.network_settings[network_settings_object.arn] = network_settings_object
+        return network_settings_object.arn
+
+    def list_network_settings(self) -> List[Dict[str, str]]:
+        return [
+            {"networkSettingsArn": network_setting.arn, "vpcId": network_setting.vpc_id}
+            for network_setting in self.network_settings.values()
+        ]
+
+    def get_network_settings(self, network_settings_arn: str) -> Dict[str, Any]:
+        return self.network_settings[network_settings_arn].to_dict()
+
+    def delete_network_settings(self, network_settings_arn: str) -> None:
+        self.network_settings.pop(network_settings_arn)
+
+    def create_browser_settings(
+        self,
+        additional_encryption_context: Any,
+        browser_policy: str,
+        client_token: str,
+        customer_managed_key: str,
+        tags: Dict[str, str],
+    ) -> str:
+        browser_settings_object = FakeBrowserSettings(
+            additional_encryption_context,
+            browser_policy,
+            client_token,
+            customer_managed_key,
+            tags,
+            self.region_name,
+            self.account_id,
+        )
+        self.browser_settings[browser_settings_object.arn] = browser_settings_object
+        return browser_settings_object.arn
+
+    def list_browser_settings(self) -> List[Dict[str, str]]:
+        return [
+            {"browserSettingsArn": browser_setting.arn}
+            for browser_setting in self.browser_settings.values()
+        ]
+
+    def get_browser_settings(self, browser_settings_arn: str) -> Dict[str, Any]:
+        return self.browser_settings[browser_settings_arn].to_dict()
+
+    def delete_browser_settings(self, browser_settings_arn: str) -> None:
+        self.browser_settings.pop(browser_settings_arn)
+
+    def create_portal(
+        self,
+        additional_encryption_context: Any,
+        authentication_type: str,
+        client_token: str,
+        customer_managed_key: str,
+        display_name: str,
+        instance_type: str,
+        max_concurrent_sessions: str,
+        tags: Dict[str, str],
+    ) -> Tuple[str, str]:
+        portal_object = FakePortal(
+            additional_encryption_context,
+            authentication_type,
+            client_token,
+            customer_managed_key,
+            display_name,
+            instance_type,
+            max_concurrent_sessions,
+            tags,
+            self.region_name,
+            self.account_id,
+        )
+        self.portals[portal_object.arn] = portal_object
+        return portal_object.arn, portal_object.portal_endpoint
+
+    def list_portals(self) -> List[Dict[str, Any]]:
+        return [
+            {
+                "authenticationType": portal.authentication_type,
+                "browserSettingsArn": portal.browser_settings_arn,
+                "browserType": portal.browser_type,
+                "creationDate": portal.creation_time,
+                "customerManagedKey": portal.customer_managed_key,
+                "displayName": portal.display_name,
+                "instanceType": portal.instance_type,
+                "ipAccessSettingsArn": portal.ip_access_settings_arn,
+                "maxConcurrentSessions": portal.max_concurrent_sessions,
+                "networkSettingsArn": portal.network_settings_arn,
+                "portalArn": portal.arn,
+                "portalEndpoint": portal.portal_endpoint,
+                "portalStatus": portal.status,
+                "rendererType": portal.renderer_type,
+                "statusReason": portal.status_reason,
+                "trustStoreArn": portal.trust_store_arn,
+                "userAccessLoggingSettingsArn": portal.user_access_logging_settings_arn,
+                "userSettingsArn": portal.user_settings_arn,
+            }
+            for portal in self.portals.values()
+        ]
+
+    def get_portal(self, portal_arn: str) -> Dict[str, Any]:
+        return self.portals[portal_arn].to_dict()
+
+    def delete_portal(self, portal_arn: str) -> None:
+        self.portals.pop(portal_arn)
+
+    def associate_browser_settings(
+        self, browser_settings_arn: str, portal_arn: str
+    ) -> Tuple[str, str]:
+        browser_settings_object = self.browser_settings[browser_settings_arn]
+        portal_object = self.portals[portal_arn]
+        browser_settings_object.associated_portal_arns.append(portal_arn)
+        portal_object.browser_settings_arn = browser_settings_arn
+        return browser_settings_arn, portal_arn
+
+    def associate_network_settings(
+        self, network_settings_arn: str, portal_arn: str
+    ) -> Tuple[str, str]:
+        network_settings_object = self.network_settings[network_settings_arn]
+        portal_object = self.portals[portal_arn]
+        network_settings_object.associated_portal_arns.append(portal_arn)
+        portal_object.network_settings_arn = network_settings_arn
+        return network_settings_arn, portal_arn
+
+
+workspacesweb_backends = BackendDict(WorkSpacesWebBackend, "workspaces-web")
diff --git a/moto/workspacesweb/responses.py b/moto/workspacesweb/responses.py
new file mode 100644
index 000000000..dcda234f3
--- /dev/null
+++ b/moto/workspacesweb/responses.py
@@ -0,0 +1,185 @@
+"""Handles incoming workspacesweb requests, invokes methods, returns responses."""
+
+import json
+from typing import Any
+from urllib.parse import unquote
+
+from moto.core.responses import TYPE_RESPONSE, BaseResponse
+
+from .models import WorkSpacesWebBackend, workspacesweb_backends
+
+
+class WorkSpacesWebResponse(BaseResponse):
+    """Handler for WorkSpacesWeb requests and responses."""
+
+    def __init__(self) -> None:
+        super().__init__(service_name="workspaces-web")
+
+    @property
+    def workspacesweb_backend(self) -> WorkSpacesWebBackend:
+        """Return backend instance specific for this region."""
+        return workspacesweb_backends[self.current_account][self.region]
+
+    @staticmethod
+    def network_settings(request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[misc]
+        handler = WorkSpacesWebResponse()
+        handler.setup_class(request, full_url, headers)
+        if request.method == "GET":
+            return handler.get_network_settings()
+        else:
+            return handler.delete_network_settings()
+
+    @staticmethod
+    def browser_settings(request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[misc]
+        handler = WorkSpacesWebResponse()
+        handler.setup_class(request, full_url, headers)
+        if request.method == "GET":
+            return handler.get_browser_settings()
+        else:
+            return handler.delete_browser_settings()
+
+    @staticmethod
+    def portal(request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[misc]
+        handler = WorkSpacesWebResponse()
+        handler.setup_class(request, full_url, headers)
+        if request.method == "GET":
+            return handler.get_portal()
+        else:
+            return handler.delete_portal()
+
+    def create_browser_settings(self) -> str:
+        additional_encryption_context = self._get_param("additionalEncryptionContext")
+        browser_policy = self._get_param("browserPolicy")
+        client_token = self._get_param("clientToken")
+        customer_managed_key = self._get_param("customerManagedKey")
+        tags = self._get_param("tags")
+        browser_settings_arn = self.workspacesweb_backend.create_browser_settings(
+            additional_encryption_context=additional_encryption_context,
+            browser_policy=browser_policy,
+            client_token=client_token,
+            customer_managed_key=customer_managed_key,
+            tags=tags,
+        )
+        return json.dumps(dict(browserSettingsArn=browser_settings_arn))
+
+    def create_network_settings(self) -> str:
+        security_group_ids = self._get_param("securityGroupIds")
+        subnet_ids = self._get_param("subnetIds")
+        tags = self._get_param("tags")
+        vpc_id = self._get_param("vpcId")
+        network_settings_arn = self.workspacesweb_backend.create_network_settings(
+            security_group_ids=security_group_ids,
+            subnet_ids=subnet_ids,
+            tags=tags,
+            vpc_id=vpc_id,
+        )
+        return json.dumps(dict(networkSettingsArn=network_settings_arn))
+
+    def get_network_settings(self) -> TYPE_RESPONSE:
+        network_settings_arn = unquote(
+            self.parsed_url.path.split("/networkSettings/")[-1]
+        )
+        network_settings = self.workspacesweb_backend.get_network_settings(
+            network_settings_arn=network_settings_arn,
+        )
+        return 200, {}, json.dumps(dict(networkSettings=network_settings))
+
+    def create_portal(self) -> str:
+        additional_encryption_context = self._get_param("additionalEncryptionContext")
+        authentication_type = self._get_param("authenticationType")
+        client_token = self._get_param("clientToken")
+        customer_managed_key = self._get_param("customerManagedKey")
+        display_name = self._get_param("displayName")
+        instance_type = self._get_param("instanceType")
+        max_concurrent_sessions = self._get_param("maxConcurrentSessions")
+        tags = self._get_param("tags")
+        portal_arn, portal_endpoint = self.workspacesweb_backend.create_portal(
+            additional_encryption_context=additional_encryption_context,
+            authentication_type=authentication_type,
+            client_token=client_token,
+            customer_managed_key=customer_managed_key,
+            display_name=display_name,
+            instance_type=instance_type,
+            max_concurrent_sessions=max_concurrent_sessions,
+            tags=tags,
+        )
+        return json.dumps(dict(portalArn=portal_arn, portalEndpoint=portal_endpoint))
+
+    def list_browser_settings(self) -> str:
+        browser_settings = self.workspacesweb_backend.list_browser_settings()
+        return json.dumps(dict(browserSettings=browser_settings))
+
+    def list_network_settings(self) -> str:
+        network_settings = self.workspacesweb_backend.list_network_settings()
+        return json.dumps(dict(networkSettings=network_settings))
+
+    def list_portals(self) -> str:
+        portals = self.workspacesweb_backend.list_portals()
+        return json.dumps(dict(portals=portals))
+
+    def get_browser_settings(self) -> TYPE_RESPONSE:
+        browser_settings_arn = unquote(
+            self.parsed_url.path.split("/browserSettings/")[-1]
+        )
+        browser_settings = self.workspacesweb_backend.get_browser_settings(
+            browser_settings_arn=browser_settings_arn,
+        )
+        return 200, {}, json.dumps(dict(browserSettings=browser_settings))
+
+    def delete_browser_settings(self) -> TYPE_RESPONSE:
+        browser_settings_arn = unquote(
+            self.parsed_url.path.split("/browserSettings/")[-1]
+        )
+        self.workspacesweb_backend.delete_browser_settings(
+            browser_settings_arn=browser_settings_arn
+        )
+        return 200, {}, "{}"
+
+    def delete_network_settings(self) -> TYPE_RESPONSE:
+        network_settings_arn = unquote(
+            self.parsed_url.path.split("/networkSettings/")[-1]
+        )
+        self.workspacesweb_backend.delete_network_settings(
+            network_settings_arn=network_settings_arn,
+        )
+        return 200, {}, "{}"
+
+    def get_portal(self) -> TYPE_RESPONSE:
+        portal_arn = unquote(self.parsed_url.path.split("/portals/")[-1])
+        portal = self.workspacesweb_backend.get_portal(portal_arn=portal_arn)
+        return 200, {}, json.dumps(dict(portal=portal))
+
+    def delete_portal(self) -> TYPE_RESPONSE:
+        portal_arn = unquote(self.parsed_url.path.split("/portals/")[-1])
+        self.workspacesweb_backend.delete_portal(portal_arn=portal_arn)
+        return 200, {}, "{}"
+
+    def associate_browser_settings(self) -> str:
+        browser_settings_arn = unquote(self._get_param("browserSettingsArn"))
+        portal_arn = unquote(
+            self.parsed_url.path.split("/portals/")[-1].split("/browserSettings")[0]
+        )
+        browser_settings_arn, portal_arn = (
+            self.workspacesweb_backend.associate_browser_settings(
+                browser_settings_arn=browser_settings_arn,
+                portal_arn=portal_arn,
+            )
+        )
+        return json.dumps(
+            dict(browserSettingsArn=browser_settings_arn, portalArn=portal_arn)
+        )
+
+    def associate_network_settings(self) -> str:
+        network_settings_arn = unquote(self._get_param("networkSettingsArn"))
+        portal_arn = unquote(
+            self.parsed_url.path.split("/portals/")[-1].split("/networkSettings")[0]
+        )
+        network_settings_arn, portal_arn = (
+            self.workspacesweb_backend.associate_network_settings(
+                network_settings_arn=network_settings_arn,
+                portal_arn=portal_arn,
+            )
+        )
+        return json.dumps(
+            dict(networkSettingsArn=network_settings_arn, portalArn=portal_arn)
+        )
diff --git a/moto/workspacesweb/urls.py b/moto/workspacesweb/urls.py
new file mode 100644
index 000000000..6fe82a99e
--- /dev/null
+++ b/moto/workspacesweb/urls.py
@@ -0,0 +1,18 @@
+"""workspacesweb base URL and path."""
+
+from .responses import WorkSpacesWebResponse
+
+url_bases = [
+    r"https?://workspaces-web\.(.+)\.amazonaws\.com",
+]
+
+url_paths = {
+    "{0}/browserSettings$": WorkSpacesWebResponse.dispatch,
+    "{0}/networkSettings$": WorkSpacesWebResponse.dispatch,
+    "{0}/portals$": WorkSpacesWebResponse.dispatch,
+    "{0}/browserSettings/(?P<browserSettingsArn>.+)$": WorkSpacesWebResponse.browser_settings,
+    "{0}/networkSettings/(?P<networkSettingsArn>.+)$": WorkSpacesWebResponse.network_settings,
+    "{0}/portals/(?P<portalArn>[^/]+)portal/(?P<uuid>[^/]+)$": WorkSpacesWebResponse.portal,
+    "{0}/portals/(?P<portalArn>.*)/browserSettings$": WorkSpacesWebResponse.dispatch,
+    "{0}/portals/(?P<portalArn>.*)/networkSettings$": WorkSpacesWebResponse.dispatch,
+}
diff --git a/other_langs/terraform/iam/data.tf b/other_langs/terraform/iam/data.tf
new file mode 100644
index 000000000..083e23c72
--- /dev/null
+++ b/other_langs/terraform/iam/data.tf
@@ -0,0 +1,20 @@
+data "aws_caller_identity" "this" {}
+
+data "aws_iam_policy_document" "this" {
+  statement {
+    actions = ["sts:AssumeRoleWithSAML"]
+    principals {
+      type        = "Federated"
+      identifiers = [aws_iam_saml_provider.this.arn]
+    }
+    condition {
+      test     = "StringEquals"
+      variable = "SAML:aud"
+      values   = ["https://signin.aws.amazon.com/saml"]
+    }
+  }
+}
+
+data "aws_iam_saml_provider" "this" {
+  arn = aws_iam_saml_provider.this.arn
+}
diff --git a/other_langs/terraform/iam/iam.tf b/other_langs/terraform/iam/iam.tf
index 1a7710d9e..812a1e536 100644
--- a/other_langs/terraform/iam/iam.tf
+++ b/other_langs/terraform/iam/iam.tf
@@ -21,4 +21,9 @@ resource "aws_iam_policy" "policy" {
   tags = {
     t1 = "v1"
   }
-}
\ No newline at end of file
+}
+
+resource "aws_iam_saml_provider" "this" {
+  name                   = "sso-test"
+  saml_metadata_document = file("${path.module}/sample-metadata.xml")
+}
diff --git a/other_langs/terraform/iam/output.tf b/other_langs/terraform/iam/output.tf
new file mode 100644
index 000000000..6f6f6a881
--- /dev/null
+++ b/other_langs/terraform/iam/output.tf
@@ -0,0 +1,11 @@
+output "saml_provider_create_date" {
+  value = data.aws_iam_saml_provider.this.create_date
+}
+
+output "saml_provider_create_name" {
+  value = data.aws_iam_saml_provider.this.name
+}
+
+output "saml_provider_saml_metadata_document" {
+  value = data.aws_iam_saml_provider.this.saml_metadata_document
+}
diff --git a/other_langs/terraform/iam/providers.tf b/other_langs/terraform/iam/providers.tf
index 3e11097e7..b6d68e709 100644
--- a/other_langs/terraform/iam/providers.tf
+++ b/other_langs/terraform/iam/providers.tf
@@ -7,6 +7,7 @@ provider "aws" {
 
   endpoints {
     iam = "http://localhost:5000"
+    sts = "http://localhost:5000"
   }
 
   access_key = "my-access-key"
diff --git a/other_langs/terraform/iam/sample-metadata.xml b/other_langs/terraform/iam/sample-metadata.xml
new file mode 100644
index 000000000..6ff6e914a
--- /dev/null
+++ b/other_langs/terraform/iam/sample-metadata.xml
@@ -0,0 +1,76 @@
+<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="urn:amazon:webservices" validUntil="2025-08-30T00:00:00Z">
+  <SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol" WantAssertionsSigned="true">
+    <KeyDescriptor use="signing">
+      <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
+        <ds:X509Data>
+          <ds:X509Certificate>MIIDcTCCAlmgAwIBAgIIGPlChvWsb9QwDQYJKoZIhvcNAQELBQAwZzEfMB0GA1UE AxMWdXJuOmFtYXpvbjp3ZWJzZXJ2aWNlczEiMCAGA1UEChMZQW1hem9uIFdlYiBT ZXJ2aWNlcywgSW5jLjETMBEGA1UECBMKV2FzaGluZ3RvbjELMAkGA1UEBhMCVVMw HhcNMjQwODMwMDAwMDAwWhcNMjUwODMwMDAwMDAwWjBnMR8wHQYDVQQDExZ1cm46 YW1hem9uOndlYnNlcnZpY2VzMSIwIAYDVQQKExlBbWF6b24gV2ViIFNlcnZpY2Vz LCBJbmMuMRMwEQYDVQQIEwpXYXNoaW5ndG9uMQswCQYDVQQGEwJVUzCCASIwDQYJ KoZIhvcNAQEBBQADggEPADCCAQoCggEBAK2IcN8WBhWhmwR5AOHJ7LIyl+f7ZiYM OjzsNZeOhvD6t8fIPqXg2Kyt2ixCCB16AQsif/bxYAoqqwrVkuBwG8V9CrDDuvXX 3WL7+hvn/JzmjyTeZxy34ySMppSvR3JNtps1C/stTs90KKAKfP56wg1RIEfA6dWX S/Ebd05Jx1miZb2j2+rX1C8Oc6RhOhBCHbbR/4fzFXxdocs4Krq6r+So2XQ8JChC 6E/c092/hCTBXtBj0D8Lk1DJWI1avMzRbrm53wH+1qTzoZMZu66gsuvWhbeH7B/Q kixxpb2IUNYNIT5A4g8tzzQLeGaMUHpvY8fuzRkX/8Zrd8CQjveOdPUCAwEAAaMh MB8wHQYDVR0OBBYEFCM8vneP4Ut+aXPdeCYsbFkUVMpeMA0GCSqGSIb3DQEBCwUA A4IBAQBCz1qeNzA4R/iXrqswc98bZXT7oj1lzkwRaDy0jsfsgyGwHIJ5VOMxnvFH 564zGlbljCOxrZG9FtdrutYGLHaLJLZdHKH2XQy2stxcP6pnZ29LyAMoIP6fq08M 1tzW0u0fMdC2Hpgi8QHGgguKW1AFFK3vj/sW1PWuMzTwB7ur5BpgMo4Wk074HIRT Z8B1RkY13EnFy7IIEIdreKLY3xwrpUZYnW0Em705T3gt0UrFKfzxrTjMPL3yXzgk wkAvGzGJZtdZ0FNc6ecGVuWkbJ7wvbYh2j557gbk9s0F+24103bfRyyM2jj7cgnK ij1wVZYobnMgnQI+ikrd7ksym6QN</ds:X509Certificate>
+        </ds:X509Data>
+      </ds:KeyInfo>
+    </KeyDescriptor>
+    <NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</NameIDFormat>
+    <NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</NameIDFormat>
+    <NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
+    <NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</NameIDFormat>
+    <NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName</NameIDFormat>
+    <NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:WindowsDomainQualifiedName</NameIDFormat>
+    <NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:kerberos</NameIDFormat>
+    <NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:entity</NameIDFormat>
+    <AssertionConsumerService index="1" isDefault="true" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="2" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://eu-north-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="3" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://me-south-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="4" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ap-south-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="5" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://eu-west-3.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="6" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ap-southeast-3.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="7" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://us-east-2.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="8" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://af-south-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="9" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://eu-west-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="10" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://eu-central-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="11" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://sa-east-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="12" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ap-east-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="13" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ap-northeast-2.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="14" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ap-northeast-3.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="15" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://eu-west-2.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="16" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://eu-south-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="17" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ap-northeast-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="18" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://us-west-2.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="19" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://us-west-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="20" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ap-southeast-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="21" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ap-southeast-2.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="22" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ca-central-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="23" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://me-central-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="24" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://eu-central-2.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="25" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://eu-south-2.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="26" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ap-south-2.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="27" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ap-southeast-4.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="28" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://il-central-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="29" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ca-west-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="30" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ap-southeast-5.signin.aws.amazon.com/saml"/>
+    <AttributeConsumingService index="1">
+      <ServiceName xml:lang="en">AWS Management Console Single Sign-On</ServiceName>
+      <RequestedAttribute isRequired="true" Name="https://aws.amazon.com/SAML/Attributes/Role" FriendlyName="RoleEntitlement"/>
+      <RequestedAttribute isRequired="true" Name="https://aws.amazon.com/SAML/Attributes/RoleSessionName" FriendlyName="RoleSessionName"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.1" FriendlyName="eduPersonAffiliation"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.2" FriendlyName="eduPersonNickname"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.3" FriendlyName="eduPersonOrgDN"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.4" FriendlyName="eduPersonOrgUnitDN"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.5" FriendlyName="eduPersonPrimaryAffiliation"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.6" FriendlyName="eduPersonPrincipalName"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.7" FriendlyName="eduPersonEntitlement"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.8" FriendlyName="eduPersonPrimaryOrgUnitDN"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.9" FriendlyName="eduPersonScopedAffiliation"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.10" FriendlyName="eduPersonTargetedID"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.11" FriendlyName="eduPersonAssurance"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.2.1.2" FriendlyName="eduOrgHomePageURI"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.2.1.3" FriendlyName="eduOrgIdentityAuthNPolicyURI"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.2.1.4" FriendlyName="eduOrgLegalName"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.2.1.5" FriendlyName="eduOrgSuperiorURI"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.2.1.6" FriendlyName="eduOrgWhitePagesURI"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:2.5.4.3" FriendlyName="cn"/>
+    </AttributeConsumingService>
+  </SPSSODescriptor>
+  <Organization>
+    <OrganizationName xml:lang="en">Amazon Web Services, Inc.</OrganizationName>
+    <OrganizationDisplayName xml:lang="en">AWS</OrganizationDisplayName>
+    <OrganizationURL xml:lang="en">https://aws.amazon.com</OrganizationURL>
+  </Organization>
+</EntityDescriptor>


Test patch:
diff --git a/.github/workflows/tests_sdk_ruby.yml b/.github/workflows/tests_sdk_ruby.yml
index 87f08bcb7..e9e6fe224 100644
--- a/.github/workflows/tests_sdk_ruby.yml
+++ b/.github/workflows/tests_sdk_ruby.yml
@@ -13,7 +13,7 @@ jobs:
     steps:
       - uses: actions/checkout@v4
       - name: Set up Ruby ${{ matrix.ruby-version }}
-        uses: ruby/setup-ruby@a6e6f86333f0a2523ece813039b8b4be04560854
+        uses: ruby/setup-ruby@52753b7da854d5c07df37391a986c76ab4615999
         with:
           ruby-version: ${{ matrix.ruby-version }}
       - name: Set up Python 3.8
diff --git a/tests/test_apigateway/test_apigateway.py b/tests/test_apigateway/test_apigateway.py
index 62e40a123..2b0a3fb06 100644
--- a/tests/test_apigateway/test_apigateway.py
+++ b/tests/test_apigateway/test_apigateway.py
@@ -19,6 +19,7 @@ def test_create_and_get_rest_api():
         name="my_api", description="this is my api", disableExecuteApiEndpoint=True
     )
     api_id = response["id"]
+    root_resource_id = response["rootResourceId"]
 
     response = client.get_rest_api(restApiId=api_id)
 
@@ -34,6 +35,7 @@ def test_create_and_get_rest_api():
         "endpointConfiguration": {"types": ["EDGE"]},
         "tags": {},
         "disableExecuteApiEndpoint": True,
+        "rootResourceId": root_resource_id,
     }
 
 
@@ -42,6 +44,7 @@ def test_update_rest_api():
     client = boto3.client("apigateway", region_name="us-west-2")
     response = client.create_rest_api(name="my_api", description="this is my api")
     api_id = response["id"]
+    root_resource_id = response["rootResourceId"]
     patchOperations = [
         {"op": "replace", "path": "/name", "value": "new-name"},
         {"op": "replace", "path": "/description", "value": "new-description"},
@@ -71,6 +74,7 @@ def test_update_rest_api():
         "endpointConfiguration": {"types": ["EDGE"]},
         "tags": {},
         "disableExecuteApiEndpoint": True,
+        "rootResourceId": root_resource_id,
     }
     # should fail with wrong apikeysoruce
     patchOperations = [
@@ -2486,3 +2490,80 @@ def test_update_path_mapping_with_unknown_stage():
     assert ex.value.response["Error"]["Message"] == "Invalid stage identifier specified"
     assert ex.value.response["Error"]["Code"] == "BadRequestException"
     assert ex.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
+
+
+@mock_aws
+def test_update_account():
+    client = boto3.client("apigateway", region_name="eu-west-1")
+
+    patch_operations = [
+        {
+            "op": "replace",
+            "path": "/cloudwatchRoleArn",
+            "value": "arn:aws:iam:123456789012:role/moto-test-apigw-role-1",
+        },
+        {"op": "add", "path": "/features", "value": "UsagePlans"},
+        {"op": "add", "path": "/features", "value": "TestFeature"},
+    ]
+
+    account = client.update_account(patchOperations=patch_operations)
+
+    assert (
+        account["cloudwatchRoleArn"]
+        == "arn:aws:iam:123456789012:role/moto-test-apigw-role-1"
+    )
+    assert account["features"] == ["UsagePlans", "TestFeature"]
+
+    patch_operations = [
+        {
+            "op": "replace",
+            "path": "/cloudwatchRoleArn",
+            "value": "arn:aws:iam:123456789012:role/moto-test-apigw-role-2",
+        },
+        {"op": "remove", "path": "/features", "value": "TestFeature"},
+    ]
+
+    account = client.update_account(patchOperations=patch_operations)
+
+    assert (
+        account["cloudwatchRoleArn"]
+        == "arn:aws:iam:123456789012:role/moto-test-apigw-role-2"
+    )
+    assert account["throttleSettings"]["burstLimit"] == 5000
+    assert account["throttleSettings"]["rateLimit"] == 10000.0
+    assert account["apiKeyVersion"] == "1"
+    assert account["features"] == ["UsagePlans"]
+
+
+@mock_aws
+def test_update_account_error():
+    client = boto3.client("apigateway", region_name="eu-west-1")
+    patch_operations = [
+        {
+            "op": "remove",
+            "path": "/features",
+            "value": "UsagePlans",
+        },
+    ]
+
+    with pytest.raises(ClientError) as ex:
+        client.update_account(patchOperations=patch_operations)
+
+    assert (
+        ex.value.response["Error"]["Message"]
+        == "Usage Plans cannot be disabled once enabled"
+    )
+    assert ex.value.response["Error"]["Code"] == "BadRequestException"
+    assert ex.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
+
+
+@mock_aws
+def test_get_account():
+    client = boto3.client("apigateway", region_name="eu-west-1")
+    account = client.get_account()
+
+    assert account["throttleSettings"]["burstLimit"] == 5000
+    assert account["throttleSettings"]["rateLimit"] == 10000.0
+    assert account["apiKeyVersion"] == "1"
+    assert "features" not in account
+    assert "cloudwatchRoleArn" not in account
diff --git a/tests/test_appsync/test_appsync.py b/tests/test_appsync/test_appsync.py
index 67f99bbc7..5f43b7a01 100644
--- a/tests/test_appsync/test_appsync.py
+++ b/tests/test_appsync/test_appsync.py
@@ -162,3 +162,299 @@ def test_list_graphql_apis():
 
     resp = client.list_graphql_apis()
     assert len(resp["graphqlApis"]) == 3
+
+
+@mock_aws
+def test_get_api_cache():
+    client = boto3.client("appsync", region_name="ap-southeast-1")
+    api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[
+        "graphqlApi"
+    ]["apiId"]
+
+    client.create_api_cache(
+        apiId=api_id,
+        ttl=300,
+        apiCachingBehavior="FULL_REQUEST_CACHING",
+        type="T2_SMALL",
+        transitEncryptionEnabled=False,
+        atRestEncryptionEnabled=False,
+        healthMetricsConfig="DISABLED",
+    )
+
+    cache = client.get_api_cache(apiId=api_id)["apiCache"]
+
+    assert cache["ttl"] == 300
+    assert cache["apiCachingBehavior"] == "FULL_REQUEST_CACHING"
+    assert cache["transitEncryptionEnabled"] is False
+    assert cache["atRestEncryptionEnabled"] is False
+    assert cache["type"] == "T2_SMALL"
+    assert cache["status"] == "AVAILABLE"
+    assert cache["healthMetricsConfig"] == "DISABLED"
+
+
+@mock_aws
+def test_get_api_cache_error():
+    client = boto3.client("appsync", region_name="ap-southeast-1")
+
+    with pytest.raises(ClientError) as exc:
+        client.get_api_cache(apiId="unknown")
+    err = exc.value.response["Error"]
+
+    assert err["Code"] == "NotFoundException"
+    assert err["Message"] == "GraphQL API unknown not found."
+
+    api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[
+        "graphqlApi"
+    ]["apiId"]
+
+    with pytest.raises(ClientError) as exc:
+        client.get_api_cache(apiId=api_id)
+    err = exc.value.response["Error"]
+
+    assert err["Code"] == "NotFoundException"
+    assert err["Message"] == (
+        "Unable to get the cache as it doesn't exist, please create the cache first."
+    )
+
+
+@mock_aws
+def test_delete_api_cache():
+    client = boto3.client("appsync", region_name="eu-west-1")
+    api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[
+        "graphqlApi"
+    ]["apiId"]
+
+    client.create_api_cache(
+        apiId=api_id,
+        ttl=300,
+        apiCachingBehavior="FULL_REQUEST_CACHING",
+        type="T2_SMALL",
+    )
+
+    client.delete_api_cache(apiId=api_id)
+
+    with pytest.raises(ClientError) as exc:
+        client.get_api_cache(apiId=api_id)
+    err = exc.value.response["Error"]
+
+    assert err["Code"] == "NotFoundException"
+    assert err["Message"] == (
+        "Unable to get the cache as it doesn't exist, please create the cache first."
+    )
+
+
+@mock_aws
+def test_delete_api_cache_error():
+    client = boto3.client("appsync", region_name="eu-west-1")
+
+    with pytest.raises(ClientError) as exc:
+        client.delete_api_cache(apiId="unknown")
+    err = exc.value.response["Error"]
+
+    assert err["Code"] == "NotFoundException"
+    assert err["Message"] == "GraphQL API unknown not found."
+
+    api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[
+        "graphqlApi"
+    ]["apiId"]
+
+    with pytest.raises(ClientError) as exc:
+        client.delete_api_cache(apiId=api_id)
+    err = exc.value.response["Error"]
+
+    assert err["Code"] == "NotFoundException"
+    assert err["Message"] == (
+        "Unable to delete the cache as it doesn't exist, please create the cache first."
+    )
+
+
+@mock_aws
+def test_create_api_cache():
+    client = boto3.client("appsync", region_name="eu-west-1")
+    api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[
+        "graphqlApi"
+    ]["apiId"]
+
+    cache = client.create_api_cache(
+        apiId=api_id,
+        ttl=300,
+        apiCachingBehavior="FULL_REQUEST_CACHING",
+        type="T2_SMALL",
+    )["apiCache"]
+
+    assert cache["ttl"] == 300
+    assert cache["apiCachingBehavior"] == "FULL_REQUEST_CACHING"
+    assert cache["transitEncryptionEnabled"] is False
+    assert cache["atRestEncryptionEnabled"] is False
+    assert cache["type"] == "T2_SMALL"
+    assert cache["status"] == "AVAILABLE"
+    assert cache["healthMetricsConfig"] == "DISABLED"
+
+
+@mock_aws
+def test_create_api_cache_advanced():
+    client = boto3.client("appsync", region_name="eu-west-1")
+    api_id = client.create_graphql_api(name="api2", authenticationType="API_KEY")[
+        "graphqlApi"
+    ]["apiId"]
+    cache = client.create_api_cache(
+        apiId=api_id,
+        ttl=500,
+        apiCachingBehavior="PER_RESOLVER_CACHING",
+        type="R4_4XLARGE",
+        transitEncryptionEnabled=True,
+        atRestEncryptionEnabled=True,
+        healthMetricsConfig="ENABLED",
+    )["apiCache"]
+    assert cache["ttl"] == 500
+    assert cache["apiCachingBehavior"] == "PER_RESOLVER_CACHING"
+    assert cache["transitEncryptionEnabled"] is True
+    assert cache["atRestEncryptionEnabled"] is True
+    assert cache["type"] == "R4_4XLARGE"
+    assert cache["status"] == "AVAILABLE"
+    assert cache["healthMetricsConfig"] == "ENABLED"
+
+
+@mock_aws
+def test_create_api_cache_error():
+    client = boto3.client("appsync", region_name="ap-southeast-1")
+    with pytest.raises(ClientError) as exc:
+        client.create_api_cache(
+            apiId="unknown",
+            ttl=100,
+            apiCachingBehavior="FULL_REQUEST_CACHING",
+            type="SMALL",
+        )
+    err = exc.value.response["Error"]
+
+    assert err["Code"] == "NotFoundException"
+    assert err["Message"] == "GraphQL API unknown not found."
+
+    api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[
+        "graphqlApi"
+    ]["apiId"]
+
+    client.create_api_cache(
+        apiId=api_id,
+        ttl=300,
+        apiCachingBehavior="FULL_REQUEST_CACHING",
+        type="T2_SMALL",
+    )
+    with pytest.raises(ClientError) as exc:
+        client.create_api_cache(
+            apiId=api_id,
+            ttl=100,
+            apiCachingBehavior="FULL_REQUEST_CACHING",
+            type="SMALL",
+        )
+    err = exc.value.response["Error"]
+
+    assert err["Code"] == "BadRequestException"
+    assert err["Message"] == "The API has already enabled caching."
+
+
+@mock_aws
+def test_update_api_cache():
+    client = boto3.client("appsync", region_name="eu-west-1")
+    api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[
+        "graphqlApi"
+    ]["apiId"]
+
+    client.create_api_cache(
+        apiId=api_id,
+        ttl=300,
+        apiCachingBehavior="FULL_REQUEST_CACHING",
+        type="T2_SMALL",
+    )["apiCache"]
+
+    cache = client.update_api_cache(
+        apiId=api_id,
+        ttl=500,
+        apiCachingBehavior="PER_RESOLVER_CACHING",
+        type="R4_4XLARGE",
+    )["apiCache"]
+    assert cache["ttl"] == 500
+    assert cache["apiCachingBehavior"] == "PER_RESOLVER_CACHING"
+    assert cache["transitEncryptionEnabled"] is False
+    assert cache["atRestEncryptionEnabled"] is False
+    assert cache["type"] == "R4_4XLARGE"
+    assert cache["status"] == "AVAILABLE"
+    assert cache["healthMetricsConfig"] == "DISABLED"
+
+    cache = client.update_api_cache(
+        apiId=api_id,
+        ttl=300,
+        apiCachingBehavior="FULL_REQUEST_CACHING",
+        type="SMALL",
+        healthMetricsConfig="ENABLED",
+    )["apiCache"]
+
+    assert cache["healthMetricsConfig"] == "ENABLED"
+
+
+@mock_aws
+def test_update_api_cache_error():
+    client = boto3.client("appsync", region_name="eu-west-1")
+    with pytest.raises(ClientError) as exc:
+        client.update_api_cache(
+            apiId="unknown",
+            ttl=100,
+            apiCachingBehavior="FULL_REQUEST_CACHING",
+            type="SMALL",
+        )
+    err = exc.value.response["Error"]
+
+    assert err["Code"] == "NotFoundException"
+    assert err["Message"] == "GraphQL API unknown not found."
+
+    api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[
+        "graphqlApi"
+    ]["apiId"]
+
+    with pytest.raises(ClientError) as exc:
+        client.update_api_cache(
+            apiId=api_id,
+            ttl=100,
+            apiCachingBehavior="FULL_REQUEST_CACHING",
+            type="SMALL",
+        )
+    err = exc.value.response["Error"]
+
+    assert err["Code"] == "NotFoundException"
+    assert (
+        err["Message"]
+        == "Unable to update the cache as it doesn't exist, please create the cache first."
+    )
+
+
+@mock_aws
+def test_flush_api_cache():
+    client = boto3.client("appsync", region_name="us-east-2")
+    with pytest.raises(ClientError) as exc:
+        client.flush_api_cache(apiId="unknown")
+    err = exc.value.response["Error"]
+
+    assert err["Code"] == "NotFoundException"
+    assert err["Message"] == "GraphQL API unknown not found."
+
+    api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[
+        "graphqlApi"
+    ]["apiId"]
+
+    with pytest.raises(ClientError) as exc:
+        client.flush_api_cache(apiId=api_id)
+    err = exc.value.response["Error"]
+
+    assert err["Code"] == "NotFoundException"
+    assert (
+        err["Message"]
+        == "Unable to flush the cache as it doesn't exist, please create the cache first."
+    )
+
+    client.create_api_cache(
+        apiId=api_id,
+        ttl=300,
+        apiCachingBehavior="FULL_REQUEST_CACHING",
+        type="T2_SMALL",
+    )["apiCache"]
+    client.flush_api_cache(apiId=api_id)
diff --git a/tests/test_dynamodb/test_dynamodb_update_expressions.py b/tests/test_dynamodb/test_dynamodb_update_expressions.py
index 3add47cfd..b14e66987 100644
--- a/tests/test_dynamodb/test_dynamodb_update_expressions.py
+++ b/tests/test_dynamodb/test_dynamodb_update_expressions.py
@@ -225,3 +225,65 @@ def test_update_item_with_empty_expression(table_name=None):
     assert (
         err["Message"] == "Invalid UpdateExpression: The expression can not be empty;"
     )
+
+
+@pytest.mark.aws_verified
+@dynamodb_aws_verified()
+def test_update_expression_with_multiple_remove_clauses(table_name=None):
+    ddb_client = boto3.client("dynamodb", "us-east-1")
+    payload = {
+        "pk": {"S": "primary_key"},
+        "current_user": {"M": {"name": {"S": "John"}, "surname": {"S": "Doe"}}},
+        "user_list": {
+            "L": [
+                {"M": {"name": {"S": "John"}, "surname": {"S": "Doe"}}},
+                {"M": {"name": {"S": "Jane"}, "surname": {"S": "Smith"}}},
+            ]
+        },
+        "some_param": {"NULL": True},
+    }
+    ddb_client.put_item(TableName=table_name, Item=payload)
+    with pytest.raises(ClientError) as exc:
+        ddb_client.update_item(
+            TableName=table_name,
+            Key={"pk": {"S": "primary_key"}},
+            UpdateExpression="REMOVE #ulist[0] SET current_user = :current_user REMOVE some_param",
+            ExpressionAttributeNames={"#ulist": "user_list"},
+            ExpressionAttributeValues={":current_user": {"M": {"name": {"S": "Jane"}}}},
+        )
+    err = exc.value.response["Error"]
+    assert err["Code"] == "ValidationException"
+    assert (
+        err["Message"]
+        == 'Invalid UpdateExpression: The "REMOVE" section can only be used once in an update expression;'
+    )
+
+
+@pytest.mark.aws_verified
+@dynamodb_aws_verified()
+def test_update_expression_remove_list_and_attribute(table_name=None):
+    ddb_client = boto3.client("dynamodb", "us-east-1")
+    payload = {
+        "pk": {"S": "primary_key"},
+        "user_list": {
+            "L": [
+                {"M": {"name": {"S": "John"}, "surname": {"S": "Doe"}}},
+                {"M": {"name": {"S": "Jane"}, "surname": {"S": "Smith"}}},
+            ]
+        },
+        "some_param": {"NULL": True},
+    }
+    ddb_client.put_item(TableName=table_name, Item=payload)
+    ddb_client.update_item(
+        TableName=table_name,
+        Key={"pk": {"S": "primary_key"}},
+        UpdateExpression="REMOVE #ulist[0], some_param",
+        ExpressionAttributeNames={"#ulist": "user_list"},
+    )
+    item = ddb_client.get_item(
+        TableName=table_name, Key={"pk": {"S": "primary_key"}, "sk": {"S": "sort_key"}}
+    )["Item"]
+    assert item == {
+        "user_list": {"L": [{"M": {"name": {"S": "Jane"}, "surname": {"S": "Smith"}}}]},
+        "pk": {"S": "primary_key"},
+    }
diff --git a/tests/test_elbv2/test_elbv2_target_health.py b/tests/test_elbv2/test_elbv2_target_health.py
index 2647839bd..47d68b5e3 100644
--- a/tests/test_elbv2/test_elbv2_target_health.py
+++ b/tests/test_elbv2/test_elbv2_target_health.py
@@ -3,6 +3,7 @@ from uuid import uuid4
 
 import boto3
 import pytest
+from botocore.exceptions import WaiterError
 
 from . import elbv2_aws_verified
 
@@ -313,8 +314,13 @@ sudo systemctl enable httpd"""
 
         if load_balancer_arn:
             conn.delete_load_balancer(LoadBalancerArn=load_balancer_arn)
-            waiter = conn.get_waiter("load_balancers_deleted")
-            waiter.wait(LoadBalancerArns=[load_balancer_arn])
+            try:
+                waiter = conn.get_waiter("load_balancers_deleted")
+                waiter.wait(LoadBalancerArns=[load_balancer_arn])
+            except WaiterError:
+                # This operations fails against botocore 1.35.15 and up
+                # https://github.com/boto/botocore/issues/3252
+                pass
 
     if target_is_aws:
         # Resources take a while to deregister
@@ -423,8 +429,13 @@ def test_describe_unknown_targets(target_is_aws=False):
 
         if load_balancer_arn:
             conn.delete_load_balancer(LoadBalancerArn=load_balancer_arn)
-            waiter = conn.get_waiter("load_balancers_deleted")
-            waiter.wait(LoadBalancerArns=[load_balancer_arn])
+            try:
+                waiter = conn.get_waiter("load_balancers_deleted")
+                waiter.wait(LoadBalancerArns=[load_balancer_arn])
+            except WaiterError:
+                # This operations fails against botocore 1.35.15 and up
+                # https://github.com/boto/botocore/issues/3252
+                pass
 
     if target_is_aws:
         # Resources take a while to deregister
diff --git a/tests/test_iam/test_iam.py b/tests/test_iam/test_iam.py
index 310d547cf..cab288905 100644
--- a/tests/test_iam/test_iam.py
+++ b/tests/test_iam/test_iam.py
@@ -67,6 +67,84 @@ MOCK_POLICY_3 = """
 }
 """
 
+MOCK_SAML_SAMPLE_METADATA = """<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="urn:amazon:webservices" validUntil="2025-08-30T00:00:00Z">
+  <SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol" WantAssertionsSigned="true">
+    <KeyDescriptor use="signing">
+      <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
+        <ds:X509Data>
+          <ds:X509Certificate>MIIDcTCCAlmgAwIBAgIIGPlChvWsb9QwDQYJKoZIhvcNAQELBQAwZzEfMB0GA1UE AxMWdXJuOmFtYXpvbjp3ZWJzZXJ2aWNlczEiMCAGA1UEChMZQW1hem9uIFdlYiBT ZXJ2aWNlcywgSW5jLjETMBEGA1UECBMKV2FzaGluZ3RvbjELMAkGA1UEBhMCVVMw HhcNMjQwODMwMDAwMDAwWhcNMjUwODMwMDAwMDAwWjBnMR8wHQYDVQQDExZ1cm46 YW1hem9uOndlYnNlcnZpY2VzMSIwIAYDVQQKExlBbWF6b24gV2ViIFNlcnZpY2Vz LCBJbmMuMRMwEQYDVQQIEwpXYXNoaW5ndG9uMQswCQYDVQQGEwJVUzCCASIwDQYJ KoZIhvcNAQEBBQADggEPADCCAQoCggEBAK2IcN8WBhWhmwR5AOHJ7LIyl+f7ZiYM OjzsNZeOhvD6t8fIPqXg2Kyt2ixCCB16AQsif/bxYAoqqwrVkuBwG8V9CrDDuvXX 3WL7+hvn/JzmjyTeZxy34ySMppSvR3JNtps1C/stTs90KKAKfP56wg1RIEfA6dWX S/Ebd05Jx1miZb2j2+rX1C8Oc6RhOhBCHbbR/4fzFXxdocs4Krq6r+So2XQ8JChC 6E/c092/hCTBXtBj0D8Lk1DJWI1avMzRbrm53wH+1qTzoZMZu66gsuvWhbeH7B/Q kixxpb2IUNYNIT5A4g8tzzQLeGaMUHpvY8fuzRkX/8Zrd8CQjveOdPUCAwEAAaMh MB8wHQYDVR0OBBYEFCM8vneP4Ut+aXPdeCYsbFkUVMpeMA0GCSqGSIb3DQEBCwUA A4IBAQBCz1qeNzA4R/iXrqswc98bZXT7oj1lzkwRaDy0jsfsgyGwHIJ5VOMxnvFH 564zGlbljCOxrZG9FtdrutYGLHaLJLZdHKH2XQy2stxcP6pnZ29LyAMoIP6fq08M 1tzW0u0fMdC2Hpgi8QHGgguKW1AFFK3vj/sW1PWuMzTwB7ur5BpgMo4Wk074HIRT Z8B1RkY13EnFy7IIEIdreKLY3xwrpUZYnW0Em705T3gt0UrFKfzxrTjMPL3yXzgk wkAvGzGJZtdZ0FNc6ecGVuWkbJ7wvbYh2j557gbk9s0F+24103bfRyyM2jj7cgnK ij1wVZYobnMgnQI+ikrd7ksym6QN</ds:X509Certificate>
+        </ds:X509Data>
+      </ds:KeyInfo>
+    </KeyDescriptor>
+    <NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</NameIDFormat>
+    <NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</NameIDFormat>
+    <NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
+    <NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</NameIDFormat>
+    <NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName</NameIDFormat>
+    <NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:WindowsDomainQualifiedName</NameIDFormat>
+    <NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:kerberos</NameIDFormat>
+    <NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:entity</NameIDFormat>
+    <AssertionConsumerService index="1" isDefault="true" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="2" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://eu-north-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="3" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://me-south-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="4" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ap-south-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="5" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://eu-west-3.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="6" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ap-southeast-3.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="7" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://us-east-2.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="8" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://af-south-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="9" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://eu-west-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="10" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://eu-central-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="11" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://sa-east-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="12" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ap-east-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="13" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ap-northeast-2.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="14" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ap-northeast-3.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="15" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://eu-west-2.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="16" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://eu-south-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="17" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ap-northeast-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="18" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://us-west-2.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="19" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://us-west-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="20" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ap-southeast-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="21" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ap-southeast-2.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="22" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ca-central-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="23" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://me-central-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="24" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://eu-central-2.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="25" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://eu-south-2.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="26" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ap-south-2.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="27" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ap-southeast-4.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="28" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://il-central-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="29" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ca-west-1.signin.aws.amazon.com/saml"/>
+    <AssertionConsumerService index="30" isDefault="false" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://ap-southeast-5.signin.aws.amazon.com/saml"/>
+    <AttributeConsumingService index="1">
+      <ServiceName xml:lang="en">AWS Management Console Single Sign-On</ServiceName>
+      <RequestedAttribute isRequired="true" Name="https://aws.amazon.com/SAML/Attributes/Role" FriendlyName="RoleEntitlement"/>
+      <RequestedAttribute isRequired="true" Name="https://aws.amazon.com/SAML/Attributes/RoleSessionName" FriendlyName="RoleSessionName"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.1" FriendlyName="eduPersonAffiliation"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.2" FriendlyName="eduPersonNickname"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.3" FriendlyName="eduPersonOrgDN"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.4" FriendlyName="eduPersonOrgUnitDN"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.5" FriendlyName="eduPersonPrimaryAffiliation"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.6" FriendlyName="eduPersonPrincipalName"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.7" FriendlyName="eduPersonEntitlement"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.8" FriendlyName="eduPersonPrimaryOrgUnitDN"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.9" FriendlyName="eduPersonScopedAffiliation"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.10" FriendlyName="eduPersonTargetedID"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.11" FriendlyName="eduPersonAssurance"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.2.1.2" FriendlyName="eduOrgHomePageURI"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.2.1.3" FriendlyName="eduOrgIdentityAuthNPolicyURI"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.2.1.4" FriendlyName="eduOrgLegalName"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.2.1.5" FriendlyName="eduOrgSuperiorURI"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:1.3.6.1.4.1.5923.1.2.1.6" FriendlyName="eduOrgWhitePagesURI"/>
+      <RequestedAttribute isRequired="false" Name="urn:oid:2.5.4.3" FriendlyName="cn"/>
+    </AttributeConsumingService>
+  </SPSSODescriptor>
+  <Organization>
+    <OrganizationName xml:lang="en">Amazon Web Services, Inc.</OrganizationName>
+    <OrganizationDisplayName xml:lang="en">AWS</OrganizationDisplayName>
+    <OrganizationURL xml:lang="en">https://aws.amazon.com</OrganizationURL>
+  </Organization>
+</EntityDescriptor>
+"""
+
 MOCK_STS_EC2_POLICY_DOCUMENT = """{
       "Version": "2012-10-17",
       "Statement": [
@@ -2554,6 +2632,34 @@ def test_create_saml_provider():
     )
 
 
+@mock_aws()
+def test_create_saml_provider_with_samlmetadatadocument():
+    conn = boto3.client("iam", region_name="us-east-1")
+    response = conn.create_saml_provider(
+        Name="TestSAMLProvider", SAMLMetadataDocument=MOCK_SAML_SAMPLE_METADATA
+    )
+    assert (
+        response["SAMLProviderArn"]
+        == f"arn:aws:iam::{ACCOUNT_ID}:saml-provider/TestSAMLProvider"
+    )
+
+
+@mock_aws()
+def test_update_saml_provider_with_samlmetadatadocument():
+    conn = boto3.client("iam", region_name="us-east-1")
+    sam_provider_create = conn.create_saml_provider(
+        Name="TestSAMLProvider", SAMLMetadataDocument="a" * 1024
+    )
+    saml_provider_update = conn.update_saml_provider(
+        SAMLMetadataDocument=MOCK_SAML_SAMPLE_METADATA,
+        SAMLProviderArn=sam_provider_create["SAMLProviderArn"],
+    )
+    response = conn.get_saml_provider(
+        SAMLProviderArn=saml_provider_update["SAMLProviderArn"]
+    )
+    assert response["SAMLMetadataDocument"] == MOCK_SAML_SAMPLE_METADATA
+
+
 @mock_aws()
 def test_get_saml_provider():
     conn = boto3.client("iam", region_name="us-east-1")
@@ -2566,6 +2672,18 @@ def test_get_saml_provider():
     assert response["SAMLMetadataDocument"] == "a" * 1024
 
 
+@mock_aws()
+def test_get_saml_provider_with_samlmetadatadocument():
+    conn = boto3.client("iam", region_name="us-east-1")
+    saml_provider_create = conn.create_saml_provider(
+        Name="TestSAMLProvider", SAMLMetadataDocument=MOCK_SAML_SAMPLE_METADATA
+    )
+    response = conn.get_saml_provider(
+        SAMLProviderArn=saml_provider_create["SAMLProviderArn"]
+    )
+    assert response["SAMLMetadataDocument"] == MOCK_SAML_SAMPLE_METADATA
+
+
 @mock_aws()
 def test_list_saml_providers():
     conn = boto3.client("iam", region_name="us-east-1")
diff --git a/tests/test_s3/test_s3.py b/tests/test_s3/test_s3.py
index 3f057eb72..6b178665f 100644
--- a/tests/test_s3/test_s3.py
+++ b/tests/test_s3/test_s3.py
@@ -526,6 +526,37 @@ def test_key_with_special_characters(key):
     assert resp["Body"].read() == b"value"
 
 
+@s3_aws_verified
+@pytest.mark.aws_verified
+@pytest.mark.parametrize("versioned", [True, False], ids=["versioned", "not versioned"])
+def test_conditional_write(versioned, bucket_name=None):
+    s3 = boto3.client("s3", region_name="us-east-1")
+    if versioned:
+        enable_versioning(bucket_name, s3)
+
+    s3.put_object(
+        Key="test_object",
+        Body="test",
+        Bucket=bucket_name,
+        IfNoneMatch="*",
+    )
+
+    with pytest.raises(ClientError) as exc:
+        s3.put_object(
+            Key="test_object",
+            Body="another_test",
+            Bucket=bucket_name,
+            IfNoneMatch="*",
+        )
+    err = exc.value.response["Error"]
+    assert err["Code"] == "PreconditionFailed"
+    assert (
+        err["Message"]
+        == "At least one of the pre-conditions you specified did not hold"
+    )
+    assert err["Condition"] == "If-None-Match"
+
+
 @mock_aws
 def test_bucket_key_listing_order():
     s3_resource = boto3.resource("s3", region_name=DEFAULT_REGION_NAME)
diff --git a/tests/test_workspacesweb/__init__.py b/tests/test_workspacesweb/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/test_workspacesweb/test_workspacesweb.py b/tests/test_workspacesweb/test_workspacesweb.py
new file mode 100644
index 000000000..854a7599b
--- /dev/null
+++ b/tests/test_workspacesweb/test_workspacesweb.py
@@ -0,0 +1,271 @@
+"""Unit tests for workspacesweb-supported APIs."""
+
+import re
+
+import boto3
+
+from moto import mock_aws
+
+# 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
+
+FAKE_SECURITY_GROUP_IDS = ["sg-0123456789abcdef0"]
+FAKE_SUBNET_IDS = ["subnet-0123456789abcdef0", "subnet-abcdef0123456789"]
+FAKE_TAGS = [
+    {"Key": "TestKey", "Value": "TestValue"},
+]
+FAKE_VPC_ID = "vpc-0123456789abcdef0"
+FAKE_KMS_KEY_ID = "abcd1234-5678-90ab-cdef-FAKEKEY"
+
+
+@mock_aws
+def test_create_network_settings():
+    client = boto3.client("workspaces-web", region_name="eu-west-1")
+    response = client.create_network_settings(
+        securityGroupIds=FAKE_SECURITY_GROUP_IDS,
+        subnetIds=FAKE_SUBNET_IDS,
+        tags=FAKE_TAGS,
+        vpcId=FAKE_VPC_ID,
+    )
+    network_settings_arn = response["networkSettingsArn"]
+    arn_regex = r"^arn:aws:workspaces-web:eu-west-1:\d{12}:network-settings/[a-f0-9-]+$"
+    assert re.match(arn_regex, network_settings_arn) is not None
+
+
+@mock_aws
+def test_list_network_settings():
+    client = boto3.client("workspaces-web", region_name="ap-southeast-1")
+    arn = client.create_network_settings(
+        securityGroupIds=FAKE_SECURITY_GROUP_IDS,
+        subnetIds=FAKE_SUBNET_IDS,
+        tags=FAKE_TAGS,
+        vpcId=FAKE_VPC_ID,
+    )["networkSettingsArn"]
+    resp = client.list_network_settings()
+    assert resp["networkSettings"][0]["networkSettingsArn"] == arn
+    assert len(resp["networkSettings"]) == 1
+
+
+@mock_aws
+def test_get_network_settings():
+    client = boto3.client("workspaces-web", region_name="ap-southeast-1")
+    arn = client.create_network_settings(
+        securityGroupIds=FAKE_SECURITY_GROUP_IDS,
+        subnetIds=FAKE_SUBNET_IDS,
+        tags=FAKE_TAGS,
+        vpcId=FAKE_VPC_ID,
+    )["networkSettingsArn"]
+    resp = client.get_network_settings(networkSettingsArn=arn)["networkSettings"]
+    assert resp["networkSettingsArn"] == arn
+
+
+@mock_aws
+def test_delete_network_settings():
+    client = boto3.client("workspaces-web", region_name="eu-west-1")
+    arn = client.create_network_settings(
+        securityGroupIds=FAKE_SECURITY_GROUP_IDS,
+        subnetIds=FAKE_SUBNET_IDS,
+        tags=FAKE_TAGS,
+        vpcId=FAKE_VPC_ID,
+    )["networkSettingsArn"]
+    client.delete_network_settings(networkSettingsArn=arn)
+    resp = client.list_network_settings()
+    assert resp["networkSettings"] == [], "Expected no network settings"
+
+
+@mock_aws
+def test_create_browser_settings():
+    client = boto3.client("workspaces-web", region_name="eu-west-1")
+    response = client.create_browser_settings(
+        additionalEncryptionContext={"Key1": "Value1", "Key2": "Value2"},
+        browserPolicy="TestBrowserPolicy",
+        clientToken="TestClient",
+        customerManagedKey=FAKE_KMS_KEY_ID,
+        tags=FAKE_TAGS,
+    )
+    browser_settings_arn = response["browserSettingsArn"]
+    arn_regex = r"^arn:aws:workspaces-web:eu-west-1:\d{12}:browser-settings/[a-f0-9-]+$"
+    assert re.match(arn_regex, browser_settings_arn) is not None
+
+
+@mock_aws
+def test_list_browser_settings():
+    client = boto3.client("workspaces-web", region_name="eu-west-1")
+    arn = client.create_browser_settings(
+        additionalEncryptionContext={"Key1": "Value1", "Key2": "Value2"},
+        browserPolicy="TestBrowserPolicy",
+        clientToken="TestClient",
+        customerManagedKey=FAKE_KMS_KEY_ID,
+        tags=FAKE_TAGS,
+    )["browserSettingsArn"]
+    resp = client.list_browser_settings()
+    assert resp["browserSettings"][0]["browserSettingsArn"] == arn
+
+
+@mock_aws
+def test_get_browser_settings():
+    client = boto3.client("workspaces-web", region_name="eu-west-1")
+    arn = client.create_browser_settings(
+        additionalEncryptionContext={"Key1": "Value1", "Key2": "Value2"},
+        browserPolicy="TestBrowserPolicy",
+        clientToken="TestClient",
+        customerManagedKey=FAKE_KMS_KEY_ID,
+        tags=FAKE_TAGS,
+    )["browserSettingsArn"]
+    resp = client.get_browser_settings(browserSettingsArn=arn)["browserSettings"]
+    assert resp["browserSettingsArn"] == arn
+
+
+@mock_aws
+def test_delete_browser_settings():
+    client = boto3.client("workspaces-web", region_name="eu-west-1")
+    arn = client.create_browser_settings(
+        additionalEncryptionContext={"Key1": "Value1", "Key2": "Value2"},
+        browserPolicy="TestBrowserPolicy",
+        clientToken="TestClient",
+        customerManagedKey=FAKE_KMS_KEY_ID,
+        tags=FAKE_TAGS,
+    )["browserSettingsArn"]
+    client.delete_browser_settings(browserSettingsArn=arn)
+    resp = client.list_browser_settings()
+    assert resp["browserSettings"] == []
+
+
+@mock_aws
+def test_create_portal():
+    client = boto3.client("workspaces-web", region_name="eu-west-1")
+    resp = client.create_portal(
+        additionalEncryptionContext={"Key1": "Value1", "Key2": "Value2"},
+        authenticationType="Standard",
+        clientToken="TestClient",
+        customerManagedKey=FAKE_KMS_KEY_ID,
+        displayName="TestDisplayName",
+        instanceType="TestInstanceType",
+        maxConcurrentSessions=5,
+        tags=FAKE_TAGS,
+    )
+    assert resp["portalArn"]
+    assert resp["portalEndpoint"]
+
+
+@mock_aws
+def test_list_portals():
+    client = boto3.client("workspaces-web", region_name="eu-west-1")
+    arn = client.create_portal(
+        additionalEncryptionContext={"Key1": "Value1", "Key2": "Value2"},
+        authenticationType="Standard",
+        clientToken="TestClient",
+        customerManagedKey=FAKE_KMS_KEY_ID,
+        displayName="TestDisplayName",
+        instanceType="TestInstanceType",
+        maxConcurrentSessions=5,
+        tags=FAKE_TAGS,
+    )["portalArn"]
+    resp = client.list_portals()
+    assert resp["portals"][0]["portalArn"] == arn
+    assert len(resp["portals"]) == 1
+    assert resp["portals"][0]["authenticationType"] == "Standard"
+
+
+@mock_aws
+def test_get_portal():
+    client = boto3.client("workspaces-web", region_name="eu-west-1")
+    arn = client.create_portal(
+        additionalEncryptionContext={"Key1": "Value1", "Key2": "Value2"},
+        authenticationType="Standard",
+        clientToken="TestClient",
+        customerManagedKey=FAKE_KMS_KEY_ID,
+        displayName="TestDisplayName",
+        instanceType="TestInstanceType",
+        maxConcurrentSessions=5,
+        tags=FAKE_TAGS,
+    )["portalArn"]
+    resp = client.get_portal(portalArn=arn)["portal"]
+    assert resp["portalArn"] == arn, f"Expected ARN {arn} in response"
+    assert (
+        resp["authenticationType"] == "Standard"
+    ), "Expected authentication type in response"
+    assert (
+        resp["instanceType"] == "TestInstanceType"
+    ), "Expected instance type in response"
+    assert (
+        resp["maxConcurrentSessions"] == 5
+    ), "Expected max concurrent sessions in response"
+
+
+@mock_aws
+def test_delete_portal():
+    client = boto3.client("workspaces-web", region_name="eu-west-1")
+    arn = client.create_portal(
+        additionalEncryptionContext={"Key1": "Value1", "Key2": "Value2"},
+        authenticationType="Standard",
+        clientToken="TestClient",
+        customerManagedKey=FAKE_KMS_KEY_ID,
+        displayName="TestDisplayName",
+        instanceType="TestInstanceType",
+        maxConcurrentSessions=5,
+        tags=FAKE_TAGS,
+    )["portalArn"]
+    client.delete_portal(portalArn=arn)
+    assert len(client.list_portals()["portals"]) == 0, "Expected no portals"
+
+
+@mock_aws
+def test_associate_browser_settings():
+    client = boto3.client("workspaces-web", region_name="eu-west-1")
+    browser_settings_arn = client.create_browser_settings(
+        additionalEncryptionContext={"Key1": "Value1", "Key2": "Value2"},
+        browserPolicy="TestBrowserPolicy",
+        clientToken="TestClient",
+        customerManagedKey=FAKE_KMS_KEY_ID,
+        tags=FAKE_TAGS,
+    )["browserSettingsArn"]
+    portal_arn = client.create_portal(
+        additionalEncryptionContext={"Key1": "Value1", "Key2": "Value2"},
+        authenticationType="Standard",
+        clientToken="TestClient",
+        customerManagedKey=FAKE_KMS_KEY_ID,
+        displayName="TestDisplayName",
+        instanceType="TestInstanceType",
+        maxConcurrentSessions=5,
+        tags=FAKE_TAGS,
+    )["portalArn"]
+    client.associate_browser_settings(
+        browserSettingsArn=browser_settings_arn, portalArn=portal_arn
+    )
+    resp = client.get_portal(portalArn=portal_arn)["portal"]
+    assert resp["browserSettingsArn"] == browser_settings_arn
+    resp = client.get_browser_settings(browserSettingsArn=browser_settings_arn)[
+        "browserSettings"
+    ]
+    assert resp["associatedPortalArns"] == [portal_arn]
+
+
+@mock_aws
+def test_associate_network_settings():
+    client = boto3.client("workspaces-web", region_name="eu-west-1")
+    network_arn = client.create_network_settings(
+        securityGroupIds=FAKE_SECURITY_GROUP_IDS,
+        subnetIds=FAKE_SUBNET_IDS,
+        tags=FAKE_TAGS,
+        vpcId=FAKE_VPC_ID,
+    )["networkSettingsArn"]
+    portal_arn = client.create_portal(
+        additionalEncryptionContext={"Key1": "Value1", "Key2": "Value2"},
+        authenticationType="Standard",
+        clientToken="TestClient",
+        customerManagedKey=FAKE_KMS_KEY_ID,
+        displayName="TestDisplayName",
+        instanceType="TestInstanceType",
+        maxConcurrentSessions=5,
+        tags=FAKE_TAGS,
+    )["portalArn"]
+    client.associate_network_settings(
+        networkSettingsArn=network_arn, portalArn=portal_arn
+    )
+    resp = client.get_portal(portalArn=portal_arn)["portal"]
+    assert resp["networkSettingsArn"] == network_arn
+    resp = client.get_network_settings(networkSettingsArn=network_arn)[
+        "networkSettings"
+    ]
+    assert resp["associatedPortalArns"] == [portal_arn]