From 011e48f5eed285cc5035cfe10be209ffea06c9a3 Mon Sep 17 00:00:00 2001 From: Dongie Agnir Date: Wed, 18 Mar 2026 12:57:04 -0700 Subject: [PATCH] SnsMessageManager Impl Provides an implementation of the SnsMessageManager. This mostly just ties all the other classes implemented in previous PRs related to the message manager. --- ...ature-AmazonSNSMessageManager-607af7b.json | 6 + .../loader/DefaultSdkHttpClientBuilder.java | 4 +- services-custom/sns-message-manager/pom.xml | 6 + .../messagemanager/sns/SnsMessageManager.java | 128 +++++++++++++++++ .../sns/internal/CertificateRetriever.java | 20 ++- .../sns/internal/CertificateUrlValidator.java | 12 +- .../internal/DefaultSnsMessageManager.java | 127 ++++++++++++++++ .../sns/internal/UnmanagedSdkHttpClient.java | 40 ++++++ .../sns/model/SignatureVersion.java | 4 +- .../internal/CertificateRetrieverTest.java | 63 +++++--- .../internal/CertificateUrlValidatorTest.java | 51 +++++++ .../DefaultSnsMessageManagerTest.java | 136 ++++++++++++++++++ .../internal/UnmanagedSdkHttpClientTest.java | 56 ++++++++ 13 files changed, 617 insertions(+), 36 deletions(-) create mode 100644 .changes/next-release/feature-AmazonSNSMessageManager-607af7b.json create mode 100644 services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/SnsMessageManager.java create mode 100644 services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/DefaultSnsMessageManager.java create mode 100644 services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/UnmanagedSdkHttpClient.java create mode 100644 services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateUrlValidatorTest.java create mode 100644 services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/DefaultSnsMessageManagerTest.java create mode 100644 services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/UnmanagedSdkHttpClientTest.java diff --git a/.changes/next-release/feature-AmazonSNSMessageManager-607af7b.json b/.changes/next-release/feature-AmazonSNSMessageManager-607af7b.json new file mode 100644 index 000000000000..83a8b158861a --- /dev/null +++ b/.changes/next-release/feature-AmazonSNSMessageManager-607af7b.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "Amazon SNS Message Manager", + "contributor": "", + "description": "This change introduces the SNS Message Manager for 2.x, a library used to parse and validate messages received from SNS. This aims to provide the same functionality as [SnsMessageManager](https://docs.aws.amazon.com/AWSJavaSDK/latest/javadoc/com/amazonaws/services/sns/message/SnsMessageManager.html) from 1.x." +} diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/loader/DefaultSdkHttpClientBuilder.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/loader/DefaultSdkHttpClientBuilder.java index 5fc4b4b45ec7..df2299276c04 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/loader/DefaultSdkHttpClientBuilder.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/loader/DefaultSdkHttpClientBuilder.java @@ -15,7 +15,7 @@ package software.amazon.awssdk.core.internal.http.loader; -import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.annotations.SdkProtectedApi; import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.http.SdkHttpClient; import software.amazon.awssdk.http.SdkHttpService; @@ -24,7 +24,7 @@ /** * Utility to load the default HTTP client factory and create an instance of {@link SdkHttpClient}. */ -@SdkInternalApi +@SdkProtectedApi public final class DefaultSdkHttpClientBuilder implements SdkHttpClient.Builder { private static final SdkHttpServiceProvider DEFAULT_CHAIN = new CachingSdkHttpServiceProvider<>( diff --git a/services-custom/sns-message-manager/pom.xml b/services-custom/sns-message-manager/pom.xml index 9fa4f195f6d9..f3aa952f2541 100644 --- a/services-custom/sns-message-manager/pom.xml +++ b/services-custom/sns-message-manager/pom.xml @@ -98,6 +98,12 @@ httpclient5 ${httpcomponents.client5.version} + + software.amazon.awssdk + apache5-client + ${project.version} + runtime + org.assertj assertj-core diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/SnsMessageManager.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/SnsMessageManager.java new file mode 100644 index 000000000000..5df9810a3a48 --- /dev/null +++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/SnsMessageManager.java @@ -0,0 +1,128 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.messagemanager.sns; + +import java.io.InputStream; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.messagemanager.sns.internal.DefaultSnsMessageManager; +import software.amazon.awssdk.messagemanager.sns.model.SnsMessage; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.utils.SdkAutoCloseable; + + +/** + * Message manager for validating SNS message signatures. Create an instance using {@link #builder()}. + * + *

This manager provides automatic validation of SNS message signatures received via HTTP/HTTPS endpoints, + * ensuring that messages originate from Amazon SNS and have not been modified during transmission. + * It supports both SignatureVersion1 (SHA1) and SignatureVersion2 (SHA256) as per AWS SNS standards. + * + *

The manager handles certificate retrieval, caching, and validation automatically, supporting different + * AWS regions and partitions (aws, aws-gov, aws-cn). + * + *

Basic usage with default configuration: + *

+ * {@code
+ * SnsMessageManager messageManager = SnsMessageManager.builder().build();
+ *
+ * try {
+ *     SnsMessage validatedMessage = messageManager.parseMessage(messageBody);
+ *     String messageContent = validatedMessage.message();
+ *     String topicArn = validatedMessage.topicArn();
+ *     // Process the validated message
+ * } catch (SdkClientException e) {
+ *     // Handle validation failure
+ *     logger.error("SNS message validation failed: {}", e.getMessage());
+ * }
+ * }
+ * 
+ * + *

Advanced usage with custom HTTP client: + *

+ * {@code
+ * SnsMessageManager messageManager = SnsMessageManager.builder()
+ *     .httpClient(ApacheHttpClient.create())
+ *     .build();
+ * }
+ * 
+ * + * @see SnsMessage + * @see Builder + */ +@SdkPublicApi +public interface SnsMessageManager extends SdkAutoCloseable { + + /** + * Creates a builder for configuring and creating an {@link SnsMessageManager}. + * + * @return A new builder. + */ + static Builder builder() { + return DefaultSnsMessageManager.builder(); + } + + /** + * Parses and validates an SNS message from a stream. + *

+ * This method reads the JSON message payload, validates the signature, returns a parsed SNS message object with all + * message attributes if validation succeeds. + */ + SnsMessage parseMessage(InputStream messageStream); + + /** + * Parses and validates an SNS message from a string. + *

+ * This method reads the JSON message payload, validates the signature, returns a parsed SNS message object with all + * message attributes if validation succeeds. + */ + SnsMessage parseMessage(String messageContent); + + /** + * Close this {@code SnsMessageManager}, releasing any resources it owned. + *

+ * Note: if you provided your own {@link SdkHttpClient}, you must close it separately. + */ + @Override + void close(); + + interface Builder { + + /** + * Sets the HTTP client to use for certificate retrieval. The caller is responsible for closing this HTTP client after + * the {@code SnsMessageManager} is closed. + * + * @param httpClient The HTTP client to use for fetching signing certificates. + * @return This builder for method chaining. + */ + Builder httpClient(SdkHttpClient httpClient); + + /** + * Sets the AWS region for certificate validation. This region must match the SNS region where the messages originate. + * + * @param region The AWS region where the SNS messages originate. + * @return This builder for method chaining. + */ + Builder region(Region region); + + /** + * Builds an instance of {@link SnsMessageManager} based on the supplied configurations. + * + * @return An initialized SnsMessageManager ready to validate SNS messages. + */ + SnsMessageManager build(); + } +} \ No newline at end of file diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateRetriever.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateRetriever.java index c22d0d340b5a..4720c2859767 100644 --- a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateRetriever.java +++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateRetriever.java @@ -40,6 +40,7 @@ import software.amazon.awssdk.http.SdkHttpRequest; import software.amazon.awssdk.utils.IoUtils; import software.amazon.awssdk.utils.Lazy; +import software.amazon.awssdk.utils.SdkAutoCloseable; import software.amazon.awssdk.utils.Validate; import software.amazon.awssdk.utils.cache.lru.LruCache; @@ -49,7 +50,7 @@ * This class retrieves the certificate used to sign a message, validates it, and caches them for future use. */ @SdkInternalApi -public final class CertificateRetriever { +public class CertificateRetriever implements SdkAutoCloseable { private static final Lazy X509_FORMAT = new Lazy<>(() -> Pattern.compile( "^[\\s]*-----BEGIN [A-Z]+-----\\n[A-Za-z\\d+\\/\\n]+[=]{0,2}\\n-----END [A-Z]+-----[\\s]*$")); @@ -61,26 +62,31 @@ public final class CertificateRetriever { private final CertificateUrlValidator certUrlValidator; private final LruCache certificateCache; - public CertificateRetriever(SdkHttpClient httpClient, String certCommonName) { - this(httpClient, certCommonName, new CertificateUrlValidator(certCommonName)); + public CertificateRetriever(SdkHttpClient httpClient, String certHost, String certCommonName) { + this(httpClient, certCommonName, new CertificateUrlValidator(certHost)); } CertificateRetriever(SdkHttpClient httpClient, String certCommonName, CertificateUrlValidator certificateUrlValidator) { this.httpClient = Validate.paramNotNull(httpClient, "httpClient"); this.certCommonName = Validate.paramNotNull(certCommonName, "certCommonName"); - this.certificateCache = LruCache.builder(this::getCertificate) + this.certificateCache = LruCache.builder(this::fetchCertificate) .maxSize(10) .build(); this.certUrlValidator = Validate.paramNotNull(certificateUrlValidator, "certificateUrlValidator"); } - public byte[] retrieveCertificate(URI certificateUrl) { + public PublicKey retrieveCertificate(URI certificateUrl) { Validate.paramNotNull(certificateUrl, "certificateUrl"); certUrlValidator.validate(certificateUrl); - return certificateCache.get(certificateUrl).getEncoded(); + return certificateCache.get(certificateUrl); } - private PublicKey getCertificate(URI certificateUrl) { + @Override + public void close() { + httpClient.close(); + } + + private PublicKey fetchCertificate(URI certificateUrl) { byte[] cert = fetchUrl(certificateUrl); validateCertificateData(cert); return createPublicKey(cert); diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateUrlValidator.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateUrlValidator.java index 75c68fcb71a1..cc5902506201 100644 --- a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateUrlValidator.java +++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateUrlValidator.java @@ -18,16 +18,18 @@ import java.net.URI; import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.utils.Validate; /** * Validates that the signing certificate URL is valid. */ @SdkInternalApi public class CertificateUrlValidator { - private final String expectedCommonName; + private final String certificateHost; - public CertificateUrlValidator(String expectedCommonName) { - this.expectedCommonName = expectedCommonName; + public CertificateUrlValidator(String certificateHost) { + Validate.notBlank(certificateHost, "Expected certificate host cannot be null or empty"); + this.certificateHost = certificateHost; } public void validate(URI certificateUrl) { @@ -39,8 +41,8 @@ public void validate(URI certificateUrl) { throw SdkClientException.create("Certificate URL must use HTTPS"); } - if (!expectedCommonName.equals(certificateUrl.getHost())) { - throw SdkClientException.create("Certificate URL does not match expected host: " + expectedCommonName); + if (!certificateHost.equals(certificateUrl.getHost())) { + throw SdkClientException.create("Certificate URL does not match expected host: " + certificateHost); } } } diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/DefaultSnsMessageManager.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/DefaultSnsMessageManager.java new file mode 100644 index 000000000000..5f9ceb1b88c6 --- /dev/null +++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/DefaultSnsMessageManager.java @@ -0,0 +1,127 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.messagemanager.sns.internal; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.PublicKey; +import java.time.Duration; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.annotations.SdkTestInternalApi; +import software.amazon.awssdk.core.internal.http.loader.DefaultSdkHttpClientBuilder; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpConfigurationOption; +import software.amazon.awssdk.messagemanager.sns.SnsMessageManager; +import software.amazon.awssdk.messagemanager.sns.model.SnsMessage; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.utils.AttributeMap; +import software.amazon.awssdk.utils.Validate; + +@SdkInternalApi +public final class DefaultSnsMessageManager implements SnsMessageManager { + private static final AttributeMap HTTP_CLIENT_DEFAULTS = + AttributeMap.builder() + .put(SdkHttpConfigurationOption.CONNECTION_TIMEOUT, Duration.ofSeconds(10)) + .put(SdkHttpConfigurationOption.READ_TIMEOUT, Duration.ofSeconds(30)) + .build(); + + private final SnsMessageUnmarshaller unmarshaller; + private final CertificateRetriever certRetriever; + private final SignatureValidator signatureValidator; + + private DefaultSnsMessageManager(BuilderImpl builder) { + this.unmarshaller = new SnsMessageUnmarshaller(); + + SnsHostProvider hostProvider = new SnsHostProvider(builder.region); + URI signingCertEndpoint = hostProvider.regionalEndpoint(); + String signingCertCommonName = hostProvider.signingCertCommonName(); + + SdkHttpClient httpClient = resolveHttpClient(builder); + certRetriever = builder.certRetriever != null + ? builder.certRetriever + : new CertificateRetriever(httpClient, signingCertEndpoint.getHost(), signingCertCommonName); + + signatureValidator = new SignatureValidator(); + } + + @Override + public SnsMessage parseMessage(InputStream message) { + Validate.notNull(message, "message cannot be null"); + + SnsMessage snsMessage = unmarshaller.unmarshall(message); + PublicKey certificate = certRetriever.retrieveCertificate(snsMessage.signingCertUrl()); + + signatureValidator.validateSignature(snsMessage, certificate); + + return snsMessage; + } + + @Override + public SnsMessage parseMessage(String message) { + Validate.notNull(message, "message cannot be null"); + return parseMessage(new ByteArrayInputStream(message.getBytes(StandardCharsets.UTF_8))); + } + + @Override + public void close() { + certRetriever.close(); + } + + public static Builder builder() { + return new BuilderImpl(); + } + + private static SdkHttpClient resolveHttpClient(BuilderImpl builder) { + if (builder.httpClient != null) { + return new UnmanagedSdkHttpClient(builder.httpClient); + } + + return new DefaultSdkHttpClientBuilder().buildWithDefaults(HTTP_CLIENT_DEFAULTS); + } + + static class BuilderImpl implements SnsMessageManager.Builder { + private Region region; + private SdkHttpClient httpClient; + + // Testing only + private CertificateRetriever certRetriever; + + @Override + public Builder httpClient(SdkHttpClient httpClient) { + this.httpClient = httpClient; + return this; + } + + @Override + public Builder region(Region region) { + this.region = region; + return this; + } + + @SdkTestInternalApi + Builder certificateRetriever(CertificateRetriever certificateRetriever) { + this.certRetriever = certificateRetriever; + return this; + } + + @Override + public SnsMessageManager build() { + return new DefaultSnsMessageManager(this); + } + } +} diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/UnmanagedSdkHttpClient.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/UnmanagedSdkHttpClient.java new file mode 100644 index 000000000000..97dbfa867a41 --- /dev/null +++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/UnmanagedSdkHttpClient.java @@ -0,0 +1,40 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.messagemanager.sns.internal; + +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.http.ExecutableHttpRequest; +import software.amazon.awssdk.http.HttpExecuteRequest; +import software.amazon.awssdk.http.SdkHttpClient; + +@SdkInternalApi +final class UnmanagedSdkHttpClient implements SdkHttpClient { + private final SdkHttpClient delegate; + + UnmanagedSdkHttpClient(SdkHttpClient delegate) { + this.delegate = delegate; + } + + @Override + public ExecutableHttpRequest prepareRequest(HttpExecuteRequest request) { + return delegate.prepareRequest(request); + } + + @Override + public void close() { + // no-op, owner of delegate is responsible for closing. + } +} diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SignatureVersion.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SignatureVersion.java index a9987eede562..971ad09975b8 100644 --- a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SignatureVersion.java +++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SignatureVersion.java @@ -16,12 +16,12 @@ package software.amazon.awssdk.messagemanager.sns.model; import java.util.Objects; -import software.amazon.awssdk.annotations.SdkProtectedApi; +import software.amazon.awssdk.annotations.SdkPublicApi; /** * The signature version used to sign an SNS message. */ -@SdkProtectedApi +@SdkPublicApi public enum SignatureVersion { VERSION_1("1"), VERSION_2("2"), diff --git a/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateRetrieverTest.java b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateRetrieverTest.java index b7ce263dc09a..bc31f5def15a 100644 --- a/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateRetrieverTest.java +++ b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateRetrieverTest.java @@ -65,23 +65,36 @@ void setUp() { mockHttpClient = mock(SdkHttpClient.class); } + @Test + void close_closesHttpClient() { + new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), CERT_COMMON_NAME).close(); + verify(mockHttpClient).close(); + } + @Test void constructor_nullHttpClient_throwsException() { - assertThatThrownBy(() -> new CertificateRetriever(null, CERT_COMMON_NAME)) + assertThatThrownBy(() -> new CertificateRetriever(null, TEST_CERT_URI.getHost(), CERT_COMMON_NAME)) .isInstanceOf(NullPointerException.class) .hasMessage("httpClient must not be null."); } @Test void constructor_nullCertCommonName_throwsException() { - assertThatThrownBy(() -> new CertificateRetriever(mockHttpClient, null)) + assertThatThrownBy(() -> new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), (String) null)) .isInstanceOf(NullPointerException.class) .hasMessage("certCommonName must not be null."); } + @Test + void constructor_nullCertHost_throwsException() { + assertThatThrownBy(() -> new CertificateRetriever(mockHttpClient, null, CERT_COMMON_NAME)) + .isInstanceOf(NullPointerException.class) + .hasMessage("Expected certificate host cannot be null or empty"); + } + @Test void retrieveCertificate_nullUrl_throwsException() { - assertThatThrownBy(() -> new CertificateRetriever(mockHttpClient, CERT_COMMON_NAME) + assertThatThrownBy(() -> new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), CERT_COMMON_NAME) .retrieveCertificate(null)) .isInstanceOf(NullPointerException.class) .hasMessage("certificateUrl must not be null."); @@ -89,7 +102,7 @@ void retrieveCertificate_nullUrl_throwsException() { @Test void retrieveCertificate_httpUrl_throwsException() { - assertThatThrownBy(() -> new CertificateRetriever(mockHttpClient, CERT_COMMON_NAME) + assertThatThrownBy(() -> new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(),CERT_COMMON_NAME) .retrieveCertificate(URI.create("http://my-service.amazonaws.com/cert.pem"))) .isInstanceOf(SdkClientException.class) .hasMessageContaining("Certificate URL must use HTTPS"); @@ -99,7 +112,8 @@ void retrieveCertificate_httpUrl_throwsException() { void retrieveCertificate_httpError_throwsException() throws IOException { mockResponse(SdkHttpFullResponse.builder().statusCode(400).build(), null); - CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, CERT_COMMON_NAME); + CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), + CERT_COMMON_NAME); assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(TEST_CERT_URI)) .isInstanceOf(SdkClientException.class) @@ -115,7 +129,8 @@ void retrieveCertificate_callThrows_throwsException() throws IOException { when(mockHttpClient.prepareRequest(any(HttpExecuteRequest.class))).thenReturn(mockExecRequest); - CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, CERT_COMMON_NAME); + CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), + CERT_COMMON_NAME); assertThatThrownBy(() -> certificateRetriever .retrieveCertificate(TEST_CERT_URI)) @@ -128,7 +143,8 @@ void retrieveCertificate_callThrows_throwsException() throws IOException { void retrieveCertificate_noResponseStream_throwsException() throws IOException { mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), null); - CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, CERT_COMMON_NAME); + CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), + CERT_COMMON_NAME); assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(TEST_CERT_URI)) .isInstanceOf(SdkClientException.class) @@ -139,7 +155,8 @@ void retrieveCertificate_noResponseStream_throwsException() throws IOException { void retrieveCertificate_emptyResponseBody_throwsException() throws IOException { mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), new byte[0]); - CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, CERT_COMMON_NAME); + CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), + CERT_COMMON_NAME); assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(TEST_CERT_URI)) .isInstanceOf(SdkClientException.class) @@ -150,7 +167,8 @@ void retrieveCertificate_emptyResponseBody_throwsException() throws IOException void retrieveCertificate_invalidCertificateFormat_throwsException() throws IOException { mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), "this is not a cert".getBytes(StandardCharsets.UTF_8)); - CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, CERT_COMMON_NAME); + CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), + CERT_COMMON_NAME); assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(TEST_CERT_URI)) .isInstanceOf(SdkClientException.class) @@ -164,7 +182,8 @@ void retrieveCertificate_nonParsableCertificate_throwsException() throws IOExcep + "-----END CERTIFICATE-----\n"; mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), certificate.getBytes(StandardCharsets.UTF_8)); - CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, CERT_COMMON_NAME); + CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), + CERT_COMMON_NAME); assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(TEST_CERT_URI)) .isInstanceOf(SdkClientException.class) @@ -174,7 +193,8 @@ void retrieveCertificate_nonParsableCertificate_throwsException() throws IOExcep @Test void retrieveCertificate_certificateExpired_throwsException() throws IOException { mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), expiredCert); - CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, CERT_COMMON_NAME); + CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), + CERT_COMMON_NAME); assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(TEST_CERT_URI)) .isInstanceOf(SdkClientException.class) @@ -184,7 +204,8 @@ void retrieveCertificate_certificateExpired_throwsException() throws IOException @Test void retrieveCertificate_certNotYetValid_throwsException() throws IOException { mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), futureValidCert); - CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, CERT_COMMON_NAME); + CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), + CERT_COMMON_NAME); assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(TEST_CERT_URI)) .isInstanceOf(SdkClientException.class) @@ -196,7 +217,7 @@ void retrieveCertificate_commonNameMismatch_throwsException() throws IOException String commonName = "my-other-service.amazonaws.com"; URI certUri = URI.create("https://" + commonName + "/cert.pem"); mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), futureValidCert); - CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, commonName); + CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, commonName, commonName); assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(certUri)) .isInstanceOf(SdkClientException.class) @@ -207,17 +228,18 @@ void retrieveCertificate_commonNameMismatch_throwsException() throws IOException void retrieveCertificate_validPemCertificate_succeeds() throws IOException { mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), validCert); - CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, CERT_COMMON_NAME); + CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), + CERT_COMMON_NAME); - assertThat(certificateRetriever.retrieveCertificate(TEST_CERT_URI)) - .hasSizeGreaterThan(0); + assertThat(certificateRetriever.retrieveCertificate(TEST_CERT_URI)).isNotNull(); } @Test void retrieveCertificate_cacheHit_returnsFromCache() throws IOException { mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), validCert); - CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, CERT_COMMON_NAME); + CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), + CERT_COMMON_NAME); certificateRetriever.retrieveCertificate(TEST_CERT_URI); certificateRetriever.retrieveCertificate(TEST_CERT_URI); @@ -229,7 +251,8 @@ void retrieveCertificate_cacheHit_returnsFromCache() throws IOException { void retrieveCertificate_differentUrls_cachesIndependently() throws IOException { mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), validCert); - CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, CERT_COMMON_NAME); + CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), + CERT_COMMON_NAME); URI cert1Url = URI.create("https://" + CERT_COMMON_NAME + "/cert1.pem"); certificateRetriever.retrieveCertificate(cert1Url); @@ -255,7 +278,7 @@ void retrieveCertificate_concurrentAccess_threadSafe() throws Exception { mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), validCert); - CertificateRetriever retriever = new CertificateRetriever(mockHttpClient, CERT_COMMON_NAME); + CertificateRetriever retriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), CERT_COMMON_NAME); for (int j = 0; j < threads; ++j) { exec.submit(() -> { start.countDown(); @@ -285,7 +308,7 @@ void retrieveCertificate_concurrentDifferentUrls_threadSafe() throws Exception { mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), validCert); - CertificateRetriever retriever = new CertificateRetriever(mockHttpClient, CERT_COMMON_NAME); + CertificateRetriever retriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), CERT_COMMON_NAME); for (int j = 0; j < threads; ++j) { URI uri = URI.create(String.format("https://" + CERT_COMMON_NAME + "/cert%d.pem", j % 2)); exec.submit(() -> { diff --git a/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateUrlValidatorTest.java b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateUrlValidatorTest.java new file mode 100644 index 000000000000..b969ef25c943 --- /dev/null +++ b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateUrlValidatorTest.java @@ -0,0 +1,51 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.messagemanager.sns.internal; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.exception.SdkClientException; + +public class CertificateUrlValidatorTest { + private static final String CERT_HOST = "my-test-service.amazonaws.com"; + + @Test + void validate_urlNull_throws() { + CertificateUrlValidator validator = new CertificateUrlValidator(CERT_HOST); + assertThatThrownBy(() -> validator.validate(null)) + .isInstanceOf(SdkClientException.class) + .hasMessage("Certificate URL cannot be null"); + } + + @Test + void validate_schemeNotHttps_throws() { + CertificateUrlValidator validator = new CertificateUrlValidator(CERT_HOST); + assertThatThrownBy(() -> validator.validate(URI.create("http://" + CERT_HOST))) + .isInstanceOf(SdkClientException.class) + .hasMessage("Certificate URL must use HTTPS"); + } + + @Test + void validate_urlHostDoesNotMatchExpectedHost_throws() { + CertificateUrlValidator validator = new CertificateUrlValidator(CERT_HOST); + assertThatThrownBy(() -> validator.validate(URI.create("https://my-other-test-service.amazonaws.com"))) + .isInstanceOf(SdkClientException.class) + .hasMessage("Certificate URL does not match expected host: " + CERT_HOST); + } + +} diff --git a/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/DefaultSnsMessageManagerTest.java b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/DefaultSnsMessageManagerTest.java new file mode 100644 index 000000000000..55f63a5938e0 --- /dev/null +++ b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/DefaultSnsMessageManagerTest.java @@ -0,0 +1,136 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.messagemanager.sns.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.io.InputStream; +import java.time.Duration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import software.amazon.awssdk.core.SdkSystemSetting; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpConfigurationOption; +import software.amazon.awssdk.http.SdkHttpService; +import software.amazon.awssdk.messagemanager.sns.SnsMessageManager; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.utils.AttributeMap; + +public class DefaultSnsMessageManagerTest { + private static SdkHttpClient.Builder mockHttpClientBuilder; + private static SdkHttpClient mockHttpClient; + + @BeforeAll + static void setup() { + mockHttpClientBuilder = mock(SdkHttpClient.Builder.class); + mockHttpClient = mock(SdkHttpClient.class); + when(mockHttpClientBuilder.buildWithDefaults(any(AttributeMap.class))).thenReturn(mockHttpClient); + } + + @Test + void close_httpClientConfiguredOnBuilder_httpClientNotClosed() { + SdkHttpClient mockClient = mock(SdkHttpClient.class); + + SnsMessageManager msgManager = DefaultSnsMessageManager.builder() + .httpClient(mockClient) + .region(Region.US_WEST_2) + .build(); + + msgManager.close(); + + verifyNoInteractions(mockClient); + } + + @Test + void parseMessage_streamNull_throws() { + SnsMessageManager msgManager = + DefaultSnsMessageManager.builder().httpClient(mockHttpClient).region(Region.US_WEST_2).build(); + + assertThatThrownBy(() -> msgManager.parseMessage((InputStream) null)) + .hasMessage("message cannot be null"); + } + + @Test + void parseMessage_stringNull_throws() { + SnsMessageManager msgManager = + DefaultSnsMessageManager.builder().httpClient(mockHttpClient).region(Region.US_WEST_2).build(); + + assertThatThrownBy(() -> msgManager.parseMessage((String) null)) + .hasMessage("message cannot be null"); + } + + @Test + void close_certRetrieverClosed() { + CertificateRetriever mockCertRetriever = mock(CertificateRetriever.class); + + SnsMessageManager msgManager = new DefaultSnsMessageManager.BuilderImpl() + .certificateRetriever(mockCertRetriever) + .region(Region.US_WEST_2) + .build(); + + msgManager.close(); + + verify(mockCertRetriever).close(); + } + + @Test + void build_httpClientNotConfigured_usesDefaultHttpClient() { + System.setProperty(SdkSystemSetting.SYNC_HTTP_SERVICE_IMPL.property(), TestHttpService.class.getName()); + try { + DefaultSnsMessageManager.builder().region(Region.US_WEST_2).build(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AttributeMap.class); + verify(mockHttpClientBuilder).buildWithDefaults(captor.capture()); + + AttributeMap buildArgs = captor.getValue(); + assertThat(buildArgs.get(SdkHttpConfigurationOption.CONNECTION_TIMEOUT)).isEqualTo(Duration.ofSeconds(10)); + assertThat(buildArgs.get(SdkHttpConfigurationOption.READ_TIMEOUT)).isEqualTo(Duration.ofSeconds(30)); + } finally { + System.clearProperty(SdkSystemSetting.SYNC_HTTP_SERVICE_IMPL.property()); + } + } + + @Test + void close_defaultHttpClientUsed_isClosed() { + System.setProperty(SdkSystemSetting.SYNC_HTTP_SERVICE_IMPL.property(), TestHttpService.class.getName()); + try { + SnsMessageManager msgManager = DefaultSnsMessageManager.builder() + .region(Region.US_WEST_2) + .build(); + msgManager.close(); + + verify(mockHttpClient).close(); + } finally { + System.clearProperty(SdkSystemSetting.SYNC_HTTP_SERVICE_IMPL.property()); + } + } + + // NOTE: needs to be public to work with the service loader + public static class TestHttpService implements SdkHttpService { + + @Override + public SdkHttpClient.Builder createHttpClientBuilder() { + return mockHttpClientBuilder; + } + } +} diff --git a/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/UnmanagedSdkHttpClientTest.java b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/UnmanagedSdkHttpClientTest.java new file mode 100644 index 000000000000..36b6471be1b8 --- /dev/null +++ b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/UnmanagedSdkHttpClientTest.java @@ -0,0 +1,56 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.messagemanager.sns.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import software.amazon.awssdk.http.HttpExecuteRequest; +import software.amazon.awssdk.http.SdkHttpClient; + +public class UnmanagedSdkHttpClientTest { + private SdkHttpClient mockHttpClient; + + @BeforeEach + void setup() { + mockHttpClient = mock(SdkHttpClient.class); + } + + @Test + void close_doesNotCloseDelegate() { + UnmanagedSdkHttpClient unmanaged = new UnmanagedSdkHttpClient(mockHttpClient); + unmanaged.close(); + verifyNoInteractions(mockHttpClient); + } + + @Test + void prepareRequest_delegatesToRealClient() { + UnmanagedSdkHttpClient unmanaged = new UnmanagedSdkHttpClient(mockHttpClient); + HttpExecuteRequest request = HttpExecuteRequest.builder().build(); + + unmanaged.prepareRequest(request); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpExecuteRequest.class); + + verify(mockHttpClient).prepareRequest(requestCaptor.capture()); + assertThat(requestCaptor.getValue()).isSameAs(request); + } +}