Repository: getmoto/moto
Pull request #7871 (2024-07-19 20:40:45)

Feature: Sagemaker list endpoints and endpoint configs
Adds support for Sagemaker list_endpoints and list_endpoint_configs methods

bblommers: Hi @JoshLevyMN, the linting should be fixed on master - would you mind rebasing these PR's? I'll have a look at the contents once we can get the build to pass.
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getmoto/moto/pull/7871?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) Report
Attention: Patch coverage is `89.23077%` with `7 lines` in your changes missing coverage. Please review.
> Project coverage is 94.35%. Comparing base [(`b6bb11e`)](https://app.codecov.io/gh/getmoto/moto/commit/b6bb11e6ef4c3c1e51e94278237ee7db65b0c89f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) to head [(`d7151f8`)](https://app.codecov.io/gh/getmoto/moto/commit/d7151f880ca65b4fcbe4eba0d3b7bf68948bd34f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).

| [Files](https://app.codecov.io/gh/getmoto/moto/pull/7871?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Patch % | Lines |
|---|---|---|
| [moto/sagemaker/models.py](https://app.codecov.io/gh/getmoto/moto/pull/7871?src=pr&el=tree&filepath=moto%2Fsagemaker%2Fmodels.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#diff-bW90by9zYWdlbWFrZXIvbW9kZWxzLnB5) | 82.50% | [7 Missing :warning: ](https://app.codecov.io/gh/getmoto/moto/pull/7871?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #7871      +/-   ##
==========================================
- Coverage   94.35%   94.35%   -0.01%     
==========================================
  Files        1113     1113              
  Lines       94650    94715      +65     
==========================================
+ Hits        89308    89367      +59     
- Misses       5342     5348       +6     
```

| [Flag](https://app.codecov.io/gh/getmoto/moto/pull/7871/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Coverage Δ | |
|---|---|---|
| [servertests](https://app.codecov.io/gh/getmoto/moto/pull/7871/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `29.20% <9.23%> (-0.02%)` | :arrow_down: |
| [unittests](https://app.codecov.io/gh/getmoto/moto/pull/7871/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `94.32% <89.23%> (-0.01%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/getmoto/moto/pull/7871?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).

JoshLevyMN: I rebased and added some additional test coverage, not sure what is causing the testserver to fail.

Patch:
diff --git a/moto/acm/models.py b/moto/acm/models.py
index 1889ff36c..898125e84 100644
--- a/moto/acm/models.py
+++ b/moto/acm/models.py
@@ -123,6 +123,7 @@ class CertBundle(BaseModel):
         arn: Optional[str] = None,
         cert_type: str = "IMPORTED",
         cert_status: str = "ISSUED",
+        cert_authority_arn: Optional[str] = None,
     ):
         self.created_at = utcnow()
         self.cert = certificate
@@ -132,6 +133,7 @@ class CertBundle(BaseModel):
         self.tags = TagHolder()
         self.type = cert_type  # Should really be an enum
         self.status = cert_status  # Should really be an enum
+        self.cert_authority_arn = cert_authority_arn
         self.in_use_by: List[str] = []
 
         # Takes care of PEM checking
@@ -158,6 +160,7 @@ class CertBundle(BaseModel):
         account_id: str,
         region: str,
         sans: Optional[List[str]] = None,
+        cert_authority_arn: Optional[str] = None,
     ) -> "CertBundle":
         unique_sans: Set[str] = set(sans) if sans else set()
 
@@ -213,8 +216,9 @@ class CertBundle(BaseModel):
         return cls(
             certificate=cert_armored,
             private_key=private_key,
-            cert_type="AMAZON_ISSUED",
+            cert_type="PRIVATE" if cert_authority_arn is not None else "AMAZON_ISSUED",
             cert_status="PENDING_VALIDATION",
+            cert_authority_arn=cert_authority_arn,
             account_id=account_id,
             region=region,
         )
@@ -357,6 +361,9 @@ class CertBundle(BaseModel):
             }
         }
 
+        if self.cert_authority_arn is not None:
+            result["Certificate"]["CertificateAuthorityArn"] = self.cert_authority_arn
+
         domain_names = set(sans + [self.common_name])
         validation_options = []
 
@@ -376,7 +383,8 @@ class CertBundle(BaseModel):
                 }
             )
 
-        result["Certificate"]["DomainValidationOptions"] = validation_options
+        if self.type == "AMAZON_ISSUED":
+            result["Certificate"]["DomainValidationOptions"] = validation_options
 
         if self.type == "IMPORTED":
             result["Certificate"]["ImportedAt"] = datetime_to_epoch(self.created_at)
@@ -404,6 +412,8 @@ class CertBundle(BaseModel):
 
 
 class AWSCertificateManagerBackend(BaseBackend):
+    MIN_PASSPHRASE_LEN = 4
+
     def __init__(self, region_name: str, account_id: str):
         super().__init__(region_name, account_id)
         self._certificates: Dict[str, CertBundle] = {}
@@ -512,6 +522,7 @@ class AWSCertificateManagerBackend(BaseBackend):
         idempotency_token: str,
         subject_alt_names: List[str],
         tags: List[Dict[str, str]],
+        cert_authority_arn: Optional[str] = None,
     ) -> str:
         """
         The parameter DomainValidationOptions has not yet been implemented
@@ -526,6 +537,7 @@ class AWSCertificateManagerBackend(BaseBackend):
             account_id=self.account_id,
             region=self.region_name,
             sans=subject_alt_names,
+            cert_authority_arn=cert_authority_arn,
         )
         if idempotency_token is not None:
             self._set_idempotency_token_arn(idempotency_token, cert.arn)
@@ -551,9 +563,17 @@ class AWSCertificateManagerBackend(BaseBackend):
     def export_certificate(
         self, certificate_arn: str, passphrase: str
     ) -> Tuple[str, str, str]:
+        if len(passphrase) < self.MIN_PASSPHRASE_LEN:
+            raise AWSValidationException(
+                "Value at 'passphrase' failed to satisfy constraint: Member must have length greater than or equal to %s"
+                % (self.MIN_PASSPHRASE_LEN)
+            )
         passphrase_bytes = base64.standard_b64decode(passphrase)
         cert_bundle = self.get_certificate(certificate_arn)
-
+        if cert_bundle.type != "PRIVATE":
+            raise AWSValidationException(
+                "Certificate ARN: %s is not a private certificate" % (certificate_arn)
+            )
         certificate = cert_bundle.cert.decode()
         certificate_chain = cert_bundle.chain.decode()
         private_key = cert_bundle.serialize_pk(passphrase_bytes)
diff --git a/moto/acm/responses.py b/moto/acm/responses.py
index 899cbbf24..9d5daa68b 100644
--- a/moto/acm/responses.py
+++ b/moto/acm/responses.py
@@ -176,6 +176,7 @@ class AWSCertificateManagerResponse(BaseResponse):
         idempotency_token = self._get_param("IdempotencyToken")
         subject_alt_names = self._get_param("SubjectAlternativeNames")
         tags = self._get_param("Tags")  # Optional
+        cert_authority_arn = self._get_param("CertificateAuthorityArn")  # Optional
 
         if subject_alt_names is not None and len(subject_alt_names) > 10:
             # There is initial AWS limit of 10
@@ -192,6 +193,7 @@ class AWSCertificateManagerResponse(BaseResponse):
             idempotency_token,
             subject_alt_names,
             tags,
+            cert_authority_arn,
         )
 
         return json.dumps({"CertificateArn": arn})
diff --git a/moto/sagemaker/models.py b/moto/sagemaker/models.py
index 9e2e56eba..991c33b3e 100644
--- a/moto/sagemaker/models.py
+++ b/moto/sagemaker/models.py
@@ -85,6 +85,18 @@ PAGINATION_MODEL = {
         "limit_default": 100,
         "unique_attribute": "cluster_node_arn",
     },
+    "list_endpoints": {
+        "input_token": "next_token",
+        "limit_key": "max_results",
+        "limit_default": 100,
+        "unique_attribute": "endpoint_arn",
+    },
+    "list_endpoint_configs": {
+        "input_token": "next_token",
+        "limit_key": "max_results",
+        "limit_default": 100,
+        "unique_attribute": "endpoint_config_arn",
+    },
 }
 
 METRIC_INFO_TYPE = Dict[str, Union[str, int, float, datetime]]
@@ -432,6 +444,15 @@ class FakeEndpoint(BaseObject, CloudFormationModel):
 
         return endpoint_variants
 
+    def summary(self) -> Dict[str, Any]:
+        return {
+            "EndpointName": self.endpoint_name,
+            "EndpointArn": self.endpoint_arn,
+            "CreationTime": self.creation_time,
+            "LastModifiedTime": self.last_modified_time,
+            "EndpointStatus": self.endpoint_status,
+        }
+
     @property
     def response_object(self) -> Dict[str, Any]:  # type: ignore[misc]
         response_object = self.gen_response_object()
@@ -647,6 +668,13 @@ class FakeEndpointConfig(BaseObject, CloudFormationModel):
             message = f"Value '{instance_type}' at 'instanceType' failed to satisfy constraint: Member must satisfy enum value set: {VALID_INSTANCE_TYPES}"
             raise ValidationError(message=message)
 
+    def summary(self) -> Dict[str, Any]:
+        return {
+            "EndpointConfigName": self.endpoint_config_name,
+            "EndpointConfigArn": self.endpoint_config_arn,
+            "CreationTime": self.creation_time,
+        }
+
     @property
     def response_object(self) -> Dict[str, Any]:  # type: ignore[misc]
         response_object = self.gen_response_object()
@@ -3965,6 +3993,95 @@ class SageMakerModelBackend(BaseBackend):
             )
         return nodes_list
 
+    @paginate(pagination_model=PAGINATION_MODEL)
+    def list_endpoints(
+        self,
+        sort_by: Optional[str],
+        sort_order: Optional[str],
+        name_contains: Optional[str],
+        creation_time_before: Optional[str],
+        creation_time_after: Optional[str],
+        last_modified_time_before: Optional[str],
+        last_modified_time_after: Optional[str],
+        status_equals: Optional[str],
+    ) -> List[FakeEndpoint]:
+        endpoints = list(self.endpoints.values())
+        if name_contains:
+            endpoints = [i for i in endpoints if name_contains in i.endpoint_name]
+        if status_equals:
+            endpoints = [i for i in endpoints if status_equals == i.endpoint_status]
+        if creation_time_before:
+            endpoints = [
+                i for i in endpoints if i.creation_time < str(creation_time_before)
+            ]
+        if creation_time_after:
+            endpoints = [
+                i for i in endpoints if i.creation_time > str(creation_time_after)
+            ]
+        if last_modified_time_before:
+            endpoints = [
+                i
+                for i in endpoints
+                if i.last_modified_time < str(last_modified_time_before)
+            ]
+        if last_modified_time_after:
+            endpoints = [
+                i
+                for i in endpoints
+                if i.last_modified_time > str(last_modified_time_after)
+            ]
+        reverse = sort_order == "Descending"
+        if sort_by == "Name":
+            endpoints = sorted(
+                endpoints, key=lambda x: x.endpoint_name, reverse=reverse
+            )
+        elif sort_by == "Status":
+            endpoints = sorted(
+                endpoints, key=lambda x: x.endpoint_status, reverse=reverse
+            )
+        else:
+            endpoints = sorted(
+                endpoints, key=lambda x: x.creation_time, reverse=reverse
+            )
+        return endpoints
+
+    @paginate(pagination_model=PAGINATION_MODEL)
+    def list_endpoint_configs(
+        self,
+        sort_by: Optional[str],
+        sort_order: Optional[str],
+        name_contains: Optional[str],
+        creation_time_before: Optional[str],
+        creation_time_after: Optional[str],
+    ) -> List[FakeEndpointConfig]:
+        endpoint_configs = list(self.endpoint_configs.values())
+        if name_contains:
+            endpoint_configs = [
+                i for i in endpoint_configs if name_contains in i.endpoint_config_name
+            ]
+        if creation_time_before:
+            endpoint_configs = [
+                i
+                for i in endpoint_configs
+                if i.creation_time < str(creation_time_before)
+            ]
+        if creation_time_after:
+            endpoint_configs = [
+                i
+                for i in endpoint_configs
+                if i.creation_time > str(creation_time_after)
+            ]
+        reverse = sort_order == "Descending"
+        if sort_by == "Name":
+            endpoint_configs = sorted(
+                endpoint_configs, key=lambda x: x.endpoint_config_name, reverse=reverse
+            )
+        else:
+            endpoint_configs = sorted(
+                endpoint_configs, key=lambda x: x.creation_time, reverse=reverse
+            )
+        return endpoint_configs
+
 
 class FakeExperiment(BaseObject):
     def __init__(
diff --git a/moto/sagemaker/responses.py b/moto/sagemaker/responses.py
index 115e784c0..9ce21ebd4 100644
--- a/moto/sagemaker/responses.py
+++ b/moto/sagemaker/responses.py
@@ -1023,3 +1023,53 @@ class SageMakerResponse(BaseResponse):
         return json.dumps(
             dict(NextToken=next_token, ClusterNodeSummaries=cluster_node_summaries)
         )
+
+    def list_endpoints(self) -> str:
+        sort_by = self._get_param("SortBy")
+        sort_order = self._get_param("SortOrder")
+        next_token = self._get_param("NextToken")
+        max_results = self._get_param("MaxResults")
+        name_contains = self._get_param("NameContains")
+        creation_time_before = self._get_param("CreationTimeBefore")
+        creation_time_after = self._get_param("CreationTimeAfter")
+        last_modified_time_before = self._get_param("LastModifiedTimeBefore")
+        last_modified_time_after = self._get_param("LastModifiedTimeAfter")
+        status_equals = self._get_param("StatusEquals")
+        endpoints, next_token = self.sagemaker_backend.list_endpoints(
+            sort_by=sort_by,
+            sort_order=sort_order,
+            next_token=next_token,
+            max_results=max_results,
+            name_contains=name_contains,
+            creation_time_before=creation_time_before,
+            creation_time_after=creation_time_after,
+            last_modified_time_before=last_modified_time_before,
+            last_modified_time_after=last_modified_time_after,
+            status_equals=status_equals,
+        )
+        endpoint_summaries = [endpoint.summary() for endpoint in endpoints]
+        return json.dumps(dict(Endpoints=endpoint_summaries, NextToken=next_token))
+
+    def list_endpoint_configs(self) -> str:
+        sort_by = self._get_param("SortBy")
+        sort_order = self._get_param("SortOrder")
+        next_token = self._get_param("NextToken")
+        max_results = self._get_param("MaxResults")
+        name_contains = self._get_param("NameContains")
+        creation_time_before = self._get_param("CreationTimeBefore")
+        creation_time_after = self._get_param("CreationTimeAfter")
+        endpoint_configs, next_token = self.sagemaker_backend.list_endpoint_configs(
+            sort_by=sort_by,
+            sort_order=sort_order,
+            next_token=next_token,
+            max_results=max_results,
+            name_contains=name_contains,
+            creation_time_before=creation_time_before,
+            creation_time_after=creation_time_after,
+        )
+        endpoint_summaries = [
+            endpoint_config.summary() for endpoint_config in endpoint_configs
+        ]
+        return json.dumps(
+            dict(EndpointConfigs=endpoint_summaries, NextToken=next_token)
+        )


Test patch:
diff --git a/tests/test_acm/test_acm.py b/tests/test_acm/test_acm.py
index aacb7c320..337124516 100644
--- a/tests/test_acm/test_acm.py
+++ b/tests/test_acm/test_acm.py
@@ -6,12 +6,9 @@ from unittest import SkipTest, mock
 import boto3
 import pytest
 from botocore.exceptions import ClientError
-from cryptography.hazmat.backends import default_backend
-from cryptography.hazmat.primitives import serialization
 from freezegun import freeze_time
 
 from moto import mock_aws, settings
-from moto.acm.models import AWS_ROOT_CA
 from moto.core import DEFAULT_ACCOUNT_ID as ACCOUNT_ID
 
 RESOURCE_FOLDER = os.path.join(os.path.dirname(__file__), "resources")
@@ -28,6 +25,7 @@ SERVER_COMMON_NAME = "*.moto.com"
 SERVER_CRT_BAD = get_resource("star_moto_com-bad.pem")
 SERVER_KEY = get_resource("star_moto_com.key")
 BAD_ARN = f"arn:aws:acm:us-east-2:{ACCOUNT_ID}:certificate/_0000000-0000-0000-0000-000000000000"
+CERT_AUTH_ARN = f"arn:aws:acm-pca:eu-central-1:{ACCOUNT_ID}:certificate-authority/12345678-1234-1234-1234-123456789012"
 
 
 def _import_cert(client):
@@ -37,6 +35,13 @@ def _import_cert(client):
     return response["CertificateArn"]
 
 
+def _pca_cert(client):
+    response = client.request_certificate(
+        DomainName=SERVER_COMMON_NAME, CertificateAuthorityArn=CERT_AUTH_ARN
+    )
+    return response["CertificateArn"]
+
+
 # Also tests GetCertificate
 @mock_aws
 def test_import_certificate():
@@ -163,7 +168,7 @@ def test_delete_certificate():
 @mock_aws
 def test_describe_certificate():
     client = boto3.client("acm", region_name="eu-central-1")
-    arn = _import_cert(client)
+    arn = client.request_certificate(DomainName=SERVER_COMMON_NAME)["CertificateArn"]
 
     try:
         resp = client.describe_certificate(CertificateArn=arn)
@@ -171,10 +176,10 @@ def test_describe_certificate():
         pytest.skip("This test requires 64-bit time_t")
     assert resp["Certificate"]["CertificateArn"] == arn
     assert resp["Certificate"]["DomainName"] == SERVER_COMMON_NAME
-    assert resp["Certificate"]["Issuer"] == "Moto"
+    assert resp["Certificate"]["Issuer"] == "Amazon"
     assert resp["Certificate"]["KeyAlgorithm"] == "RSA_2048"
-    assert resp["Certificate"]["Status"] == "ISSUED"
-    assert resp["Certificate"]["Type"] == "IMPORTED"
+    assert resp["Certificate"]["Status"] == "PENDING_VALIDATION"
+    assert resp["Certificate"]["Type"] == "AMAZON_ISSUED"
     assert resp["Certificate"]["RenewalEligibility"] == "INELIGIBLE"
     assert "Options" in resp["Certificate"]
 
@@ -194,25 +199,76 @@ def test_describe_certificate_with_bad_arn():
 
 
 @mock_aws
-def test_export_certificate():
+def test_describe_certificate_with_imported_cert():
     client = boto3.client("acm", region_name="eu-central-1")
     arn = _import_cert(client)
 
-    resp = client.export_certificate(CertificateArn=arn, Passphrase="pass")
-    assert resp["Certificate"] == SERVER_CRT.decode()
-    assert resp["CertificateChain"] == CA_CRT.decode() + "\n" + AWS_ROOT_CA.decode()
+    try:
+        resp = client.describe_certificate(CertificateArn=arn)
+    except OverflowError:
+        pytest.skip("This test requires 64-bit time_t")
+    assert resp["Certificate"]["CertificateArn"] == arn
+    assert resp["Certificate"]["DomainName"] == SERVER_COMMON_NAME
+    assert resp["Certificate"]["Issuer"] == "Moto"
+    assert resp["Certificate"]["KeyAlgorithm"] == "RSA_2048"
+    assert resp["Certificate"]["Status"] == "ISSUED"
+    assert resp["Certificate"]["Type"] == "IMPORTED"
+    assert resp["Certificate"]["RenewalEligibility"] == "INELIGIBLE"
+    assert "Options" in resp["Certificate"]
+
+    assert "DomainValidationOptions" not in resp["Certificate"]
+
+
+@mock_aws
+def test_describe_certificate_with_pca_cert():
+    client = boto3.client("acm", region_name="eu-central-1")
+    arn = _pca_cert(client)
+    try:
+        resp = client.describe_certificate(CertificateArn=arn)
+    except OverflowError:
+        pytest.skip("This test requires 64-bit time_t")
+    assert resp["Certificate"]["CertificateArn"] == arn
+    assert resp["Certificate"]["DomainName"] == SERVER_COMMON_NAME
+    assert resp["Certificate"]["Issuer"] == "Amazon"
+    assert resp["Certificate"]["KeyAlgorithm"] == "RSA_2048"
+    assert resp["Certificate"]["Status"] == "PENDING_VALIDATION"
+    assert resp["Certificate"]["Type"] == "PRIVATE"
+    assert resp["Certificate"]["RenewalEligibility"] == "INELIGIBLE"
+    assert "Options" in resp["Certificate"]
+    assert resp["Certificate"]["CertificateAuthorityArn"] == CERT_AUTH_ARN
+    assert "DomainValidationOptions" not in resp["Certificate"]
+
+
+@mock_aws
+def test_export_certificate_with_pca_cert():
+    client = boto3.client("acm", region_name="eu-central-1")
+    arn = _pca_cert(client)
+    resp = client.export_certificate(CertificateArn=arn, Passphrase="passphrase")
+    assert "CertificateChain" in resp
+    assert "Certificate" in resp
     assert "PrivateKey" in resp
 
-    key = serialization.load_pem_private_key(
-        bytes(resp["PrivateKey"], "utf-8"), password=b"pass", backend=default_backend()
-    )
 
-    private_key = key.private_bytes(
-        encoding=serialization.Encoding.PEM,
-        format=serialization.PrivateFormat.TraditionalOpenSSL,
-        encryption_algorithm=serialization.NoEncryption(),
-    )
-    assert private_key == SERVER_KEY
+@mock_aws
+def test_export_certificate_with_imported_cert():
+    client = boto3.client("acm", region_name="eu-central-1")
+    arn = _import_cert(client)
+
+    with pytest.raises(ClientError) as err:
+        client.export_certificate(CertificateArn=arn, Passphrase="passphrase")
+    assert err.value.response["Error"]["Code"] == "ValidationException"
+    assert "is not a private certificate" in err.value.response["Error"]["Message"]
+
+
+@mock_aws
+def test_export_certificate_with_short_passphrase():
+    client = boto3.client("acm", region_name="eu-central-1")
+    arn = _pca_cert(client)
+
+    with pytest.raises(ClientError) as err:
+        client.export_certificate(CertificateArn=arn, Passphrase="")
+    assert err.value.response["Error"]["Code"] == "ValidationException"
+    assert "passphrase" in err.value.response["Error"]["Message"]
 
 
 @mock_aws
@@ -470,6 +526,17 @@ def test_request_certificate_with_optional_arguments():
     assert arn_1 != arn_4  # if tags are matched, ACM would have returned same arn
 
 
+@mock_aws
+def test_request_certificate_with_certificate_authority():
+    client = boto3.client("acm", region_name="eu-central-1")
+
+    resp = client.request_certificate(
+        DomainName="example.com",
+        CertificateAuthorityArn="arn:aws:acm-pca:eu-central-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012",
+    )
+    assert "CertificateArn" in resp
+
+
 @mock_aws
 def test_operations_with_invalid_tags():
     client = boto3.client("acm", region_name="eu-central-1")
diff --git a/tests/test_sagemaker/test_sagemaker_endpoint.py b/tests/test_sagemaker/test_sagemaker_endpoint.py
index 755603061..773fcbd71 100644
--- a/tests/test_sagemaker/test_sagemaker_endpoint.py
+++ b/tests/test_sagemaker/test_sagemaker_endpoint.py
@@ -540,6 +540,135 @@ def test_update_endpoint_weights_and_capacities_should_throw_clienterror_nonuniq
     assert resp == old_resp
 
 
+def test_list_endpoints(sagemaker_client):
+    _set_up_sagemaker_resources(
+        sagemaker_client, TEST_ENDPOINT_NAME, TEST_ENDPOINT_CONFIG_NAME, TEST_MODEL_NAME
+    )
+    _set_up_sagemaker_resources(
+        sagemaker_client, "MyEndpoint2", "MyEndpointConfig2", "MyModel2"
+    )
+
+    response = sagemaker_client.list_endpoints()
+    assert len(response["Endpoints"]) == 2
+    assert response["Endpoints"][0]["EndpointName"] == TEST_ENDPOINT_NAME
+    assert (
+        response["Endpoints"][0]["EndpointArn"]
+        == "arn:aws:sagemaker:us-east-1:123456789012:endpoint/MyEndpoint"
+    )
+    assert response["Endpoints"][0]["EndpointStatus"] == "InService"
+    assert response["Endpoints"][1]["EndpointName"] == "MyEndpoint2"
+    assert (
+        response["Endpoints"][1]["EndpointArn"]
+        == "arn:aws:sagemaker:us-east-1:123456789012:endpoint/MyEndpoint2"
+    )
+    assert response["Endpoints"][1]["EndpointStatus"] == "InService"
+    assert isinstance(response["Endpoints"][0]["CreationTime"], datetime.datetime)
+    assert isinstance(response["Endpoints"][0]["LastModifiedTime"], datetime.datetime)
+
+
+def test_list_endpoints_filters(sagemaker_client):
+    _set_up_sagemaker_resources(
+        sagemaker_client, TEST_ENDPOINT_NAME, TEST_ENDPOINT_CONFIG_NAME, TEST_MODEL_NAME
+    )
+    _set_up_sagemaker_resources(
+        sagemaker_client, "OtherEndpoint", "MyOtherEndpointConfig", "MyOtherModel"
+    )
+    _set_up_sagemaker_resources(
+        sagemaker_client, "MyEndpoint2", "MyEndpointConfig2", "MyModel2"
+    )
+
+    response = sagemaker_client.list_endpoints(
+        SortBy="name",
+        SortOrder="Ascending",
+        NameContains="MyEndpoint",
+        CreationTimeBefore=datetime.datetime(2099, 1, 1),
+        CreationTimeAfter=datetime.datetime(2021, 1, 1),
+        LastModifiedTimeBefore=datetime.datetime(2099, 1, 1),
+        LastModifiedTimeAfter=datetime.datetime(2021, 1, 1),
+        StatusEquals="InService",
+    )
+    assert len(response["Endpoints"]) == 2
+    assert response["Endpoints"][0]["EndpointName"] == TEST_ENDPOINT_NAME
+    assert (
+        response["Endpoints"][0]["EndpointArn"]
+        == "arn:aws:sagemaker:us-east-1:123456789012:endpoint/MyEndpoint"
+    )
+    assert response["Endpoints"][0]["EndpointStatus"] == "InService"
+    assert response["Endpoints"][1]["EndpointName"] == "MyEndpoint2"
+    assert (
+        response["Endpoints"][1]["EndpointArn"]
+        == "arn:aws:sagemaker:us-east-1:123456789012:endpoint/MyEndpoint2"
+    )
+    assert response["Endpoints"][1]["EndpointStatus"] == "InService"
+    assert isinstance(response["Endpoints"][0]["CreationTime"], datetime.datetime)
+    assert isinstance(response["Endpoints"][0]["LastModifiedTime"], datetime.datetime)
+    assert isinstance(response["Endpoints"][1]["CreationTime"], datetime.datetime)
+    assert isinstance(response["Endpoints"][1]["LastModifiedTime"], datetime.datetime)
+
+
+def test_list_endpoint_configs(sagemaker_client):
+    _set_up_sagemaker_resources(
+        sagemaker_client, TEST_ENDPOINT_NAME, TEST_ENDPOINT_CONFIG_NAME, TEST_MODEL_NAME
+    )
+    _set_up_sagemaker_resources(
+        sagemaker_client, "MyEndpoint2", "MyEndpointConfig2", "MyModel2"
+    )
+
+    response = sagemaker_client.list_endpoint_configs()
+    assert len(response["EndpointConfigs"]) == 2
+    assert (
+        response["EndpointConfigs"][0]["EndpointConfigName"]
+        == TEST_ENDPOINT_CONFIG_NAME
+    )
+    assert (
+        response["EndpointConfigs"][0]["EndpointConfigArn"]
+        == "arn:aws:sagemaker:us-east-1:123456789012:endpoint-config/MyEndpointConfig"
+    )
+    assert response["EndpointConfigs"][1]["EndpointConfigName"] == "MyEndpointConfig2"
+    assert (
+        response["EndpointConfigs"][1]["EndpointConfigArn"]
+        == "arn:aws:sagemaker:us-east-1:123456789012:endpoint-config/MyEndpointConfig2"
+    )
+    assert isinstance(response["EndpointConfigs"][0]["CreationTime"], datetime.datetime)
+    assert isinstance(response["EndpointConfigs"][1]["CreationTime"], datetime.datetime)
+
+
+def test_list_endpoint_configs_filters(sagemaker_client):
+    _set_up_sagemaker_resources(
+        sagemaker_client, TEST_ENDPOINT_NAME, TEST_ENDPOINT_CONFIG_NAME, TEST_MODEL_NAME
+    )
+    _set_up_sagemaker_resources(
+        sagemaker_client, "OtherEndpoint", "MyOtherEndpointConfig", "MyOtherModel"
+    )
+    _set_up_sagemaker_resources(
+        sagemaker_client, "MyEndpoint2", "MyEndpointConfig2", "MyModel2"
+    )
+
+    response = sagemaker_client.list_endpoint_configs(
+        SortBy="name",
+        SortOrder="Ascending",
+        NameContains="MyEndpoint",
+        CreationTimeBefore=datetime.datetime(2099, 1, 1),
+        CreationTimeAfter=datetime.datetime(2021, 1, 1),
+    )
+    assert len(response["EndpointConfigs"]) == 2
+    assert (
+        response["EndpointConfigs"][0]["EndpointConfigName"]
+        == TEST_ENDPOINT_CONFIG_NAME
+    )
+    assert (
+        response["EndpointConfigs"][0]["EndpointConfigArn"]
+        == "arn:aws:sagemaker:us-east-1:123456789012:endpoint-config/MyEndpointConfig"
+    )
+    assert response["EndpointConfigs"][1]["EndpointConfigName"] == "MyEndpointConfig2"
+    assert (
+        response["EndpointConfigs"][1]["EndpointConfigArn"]
+        == "arn:aws:sagemaker:us-east-1:123456789012:endpoint-config/MyEndpointConfig2"
+    )
+    assert isinstance(response["EndpointConfigs"][0]["CreationTime"], datetime.datetime)
+    assert isinstance(response["EndpointConfigs"][1]["CreationTime"], datetime.datetime)
+
+
 def _set_up_sagemaker_resources(
     boto_client,
     endpoint_name,