diff --git a/.brazil.json b/.brazil.json index bccb35fdf6a3..60f91ecba9b7 100644 --- a/.brazil.json +++ b/.brazil.json @@ -31,6 +31,7 @@ "regions": { "packageName": "AwsJavaSdk-Core-Regions" }, "s3-transfer-manager": { "packageName": "AwsJavaSdk-S3-TransferManager" }, "s3-event-notifications": { "packageName": "AwsJavaSdk-S3-EventNotifications" }, + "sns-message-manager": { "packageName": "AwsJavaSdk-Sns-MessageManager" }, "sdk-core": { "packageName": "AwsJavaSdk-Core" }, "url-connection-client": { "packageName": "AwsJavaSdk-HttpClient-UrlConnectionClient" }, "utils": { "packageName": "AwsJavaSdk-Core-Utils" }, 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/aws-sdk-java/pom.xml b/aws-sdk-java/pom.xml index 991459ecbcd4..bf5aa9b9fd69 100644 --- a/aws-sdk-java/pom.xml +++ b/aws-sdk-java/pom.xml @@ -698,6 +698,11 @@ Amazon AutoScaling, etc). s3-event-notifications ${awsjavasdk.version} + + software.amazon.awssdk + sns-message-manager + ${awsjavasdk.version} + software.amazon.awssdk sagemaker diff --git a/bom/pom.xml b/bom/pom.xml index 05a484cec18c..051f892751fc 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -237,6 +237,11 @@ s3-event-notifications ${awsjavasdk.version} + + software.amazon.awssdk + sns-message-manager + ${awsjavasdk.version} + software.amazon.awssdk aws-crt-client diff --git a/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml b/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml index 69a7894bfe94..2dd0016c9f09 100644 --- a/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml +++ b/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml @@ -346,6 +346,8 @@ + + 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..230557482ce3 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,9 @@ /** * Utility to load the default HTTP client factory and create an instance of {@link SdkHttpClient}. */ -@SdkInternalApi +// NOTE: This was previously @SdkInternalApi, which is why it's in the .internal. package. It was moved to a protected API to +// allow usage outside of core for modules that need to use an HTTP client directly, such as sns-message-manager. +@SdkProtectedApi public final class DefaultSdkHttpClientBuilder implements SdkHttpClient.Builder { private static final SdkHttpServiceProvider DEFAULT_CHAIN = new CachingSdkHttpServiceProvider<>( diff --git a/docs/design/services/sns/sns-message-manager/design.md b/docs/design/services/sns/sns-message-manager/design.md new file mode 100644 index 000000000000..83cc96e1e40e --- /dev/null +++ b/docs/design/services/sns/sns-message-manager/design.md @@ -0,0 +1,326 @@ +# Design Document + +## Overview + +The SnsMessageManager feature provides automatic validation of SNS message signatures in AWS SDK for Java v2, following the same architectural pattern as the SqsAsyncBatchManager. This utility will be implemented as a separate manager class within the SNS service module that handles the parsing and cryptographic verification of SNS messages received via HTTP/HTTPS endpoints. + +The design follows the established AWS SDK v2 patterns for utility classes, providing a clean API for developers to validate SNS message authenticity without requiring deep knowledge of the underlying cryptographic verification process. + +## Usage Examples + +### Example 1: Basic Message Validation + +```java +// Create the message manager +SnsMessageManager messageManager = SnsMessageManager.builder().build(); + +// Validate a message from HTTP request body +String messageBody = request.getBody(); // JSON message from SNS +try { + SnsMessage validatedMessage = messageManager.parseMessage(messageBody); + + // Access message content + String messageContent = validatedMessage.message(); + String topicArn = validatedMessage.topicArn(); + String messageType = validatedMessage.type(); + + // Process the validated message + processNotification(messageContent, topicArn); + +} catch (SnsMessageValidationException e) { + // Handle validation failure + logger.error("SNS message validation failed: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); +} +``` + +### Example 2: Custom Configuration + +```java +// Configure certificate caching and timeouts +SnsMessageManager messageManager = SnsMessageManager.builder() + .configuration(config -> config + .certificateCacheTimeout(Duration.ofHours(1)) + .httpTimeout(Duration.ofSeconds(10)) + .strictCertificateValidation(true)) + .build(); + +// Validate message with custom configuration +SnsMessage message = messageManager.parseMessage(inputStream); +``` + +### Example 3: Handling Different Message Types + +```java +SnsMessageManager messageManager = SnsMessageManager.builder().build(); + +try { + SnsMessage message = messageManager.parseMessage(messageJson); + + switch (message.type()) { + case "Notification": + handleNotification(message.message(), message.subject()); + break; + case "SubscriptionConfirmation": + confirmSubscription(message.token(), message.topicArn()); + break; + case "UnsubscribeConfirmation": + handleUnsubscribe(message.token(), message.topicArn()); + break; + default: + logger.warn("Unknown message type: {}", message.type()); + } + +} catch (SnsSignatureValidationException e) { + logger.error("Invalid signature: {}", e.getMessage()); +} catch (SnsMessageParsingException e) { + logger.error("Malformed message: {}", e.getMessage()); +} catch (SnsCertificateException e) { + logger.error("Certificate error: {}", e.getMessage()); +} +``` + + + +## Architecture + +### Sync vs Async Support Decision + +Unlike SqsBatchManager which provides async support for batching operations, SNS message validation is **synchronous by nature** - you receive a message and need to validate it immediately before processing. + +We will start with **sync-only support** (`SnsMessageManager`) for the following reasons: +- Most common use case is HTTP endpoint handlers requiring immediate validation +- Simpler implementation and maintenance +- Can add async support later if customer demand emerges +- Follows YAGNI principle - avoid unnecessary complexity + +### Package Structure +``` +services/sns/src/main/java/software/amazon/awssdk/services/sns/ +├── messagemanager/ +│ ├── SnsMessageManager.java (public interface) +│ └── MessageManagerConfiguration.java (configuration class) +└── internal/ + └── messagemanager/ + ├── DefaultSnsMessageManager.java (implementation) + ├── SnsMessageParser.java (message parsing logic) + ├── SignatureValidator.java (signature validation) + ├── CertificateRetriever.java (certificate management) + └── SnsMessageImpl.java (message representation) +``` + +### Core Components + +#### 1. SnsMessageManager (Public Interface) +- Main entry point for developers +- Provides `parseMessage()` methods for validation +- Follows builder pattern similar to other SDK utilities +- Thread-safe and reusable + +#### 2. MessageManagerConfiguration +- Configuration class for customizing validation behavior +- Controls certificate caching, timeout settings +- Similar to other SDK configuration classes + +#### 3. DefaultSnsMessageManager (Internal Implementation) +- Implements the SnsMessageManager interface +- Coordinates between parser, validator, and certificate retriever +- Manages configuration and lifecycle + +#### 4. SnsMessageParser +- Parses JSON message payload +- Extracts signature fields and message content +- Validates message format and required fields + +#### 5. SignatureValidator +- Performs cryptographic signature verification using SHA1 (SignatureVersion1) and SHA256 (SignatureVersion2) +- Uses AWS certificate to validate message authenticity +- Handles different signature versions and validates certificate chain of trust + +#### 6. CertificateRetriever +- Retrieves and caches SNS certificates using HTTPS only +- Validates certificate URLs against known SNS-signed domains +- Supports different AWS regions and partitions (aws, aws-gov, aws-cn) +- Verifies certificate chain of trust and Amazon SNS issuance + +## Components and Interfaces + +### SnsMessageManager Interface +```java +@SdkPublicApi +public interface SnsMessageManager extends SdkAutoCloseable { + + static Builder builder() { + return DefaultSnsMessageManager.builder(); + } + + /** + * Parses and validates an SNS message from InputStream + */ + SnsMessage parseMessage(InputStream messageStream); + + /** + * Parses and validates an SNS message from String + */ + SnsMessage parseMessage(String messageContent); + + interface Builder extends CopyableBuilder { + Builder configuration(MessageManagerConfiguration configuration); + Builder configuration(Consumer configuration); + SnsMessageManager build(); + } +} +``` + +### SnsMessage Interface +```java +@SdkPublicApi +public interface SnsMessage { + String type(); + String messageId(); + String topicArn(); + String subject(); + String message(); + Instant timestamp(); + String signatureVersion(); + String signature(); + String signingCertUrl(); + String unsubscribeUrl(); + String token(); + Map messageAttributes(); +} +``` + +### MessageManagerConfiguration +```java +@SdkPublicApi +@Immutable +@ThreadSafe +public final class MessageManagerConfiguration + implements ToCopyableBuilder { + + private final Duration certificateCacheTimeout; + private final SdkHttpClient httpClient; + + // Constructor, getters, toBuilder() implementation + + public static Builder builder() { + return new DefaultMessageManagerConfigurationBuilder(); + } + + public Duration certificateCacheTimeout() { return certificateCacheTimeout; } + public SdkHttpClient httpClient() { return httpClient; } + + @NotThreadSafe + public interface Builder extends CopyableBuilder { + Builder certificateCacheTimeout(Duration certificateCacheTimeout); + Builder httpClient(SdkHttpClient httpClient); + } +} +``` + +## Data Models + +### Message Types +The manager will support all standard SNS message types: +- **Notification**: Standard SNS notifications +- **SubscriptionConfirmation**: Subscription confirmation messages +- **UnsubscribeConfirmation**: Unsubscribe confirmation messages + +### Message Fields +Standard SNS message fields that will be parsed and validated: +- Type (required) +- MessageId (required) +- TopicArn (required) +- Message (required for Notification) +- Timestamp (required) +- SignatureVersion (required) +- Signature (required) +- SigningCertURL (required) +- Subject (optional) +- UnsubscribeURL (optional for Notification) +- Token (required for confirmations) +- MessageAttributes (optional) + +### Certificate Management +- Certificate URLs will be validated against known AWS SNS-signed domains only +- Certificates retrieved exclusively via HTTPS to prevent interception attacks +- Certificate chain of trust validation to ensure Amazon SNS issuance +- Certificates will be cached with configurable TTL +- Support for different AWS partitions (aws, aws-gov, aws-cn) +- Rejection of any certificates provided directly in messages without validation + +## Error Handling + +### Exception Hierarchy +```java +public class SnsMessageValidationException extends SdkException { + // Base exception for all validation failures +} + +public class SnsMessageParsingException extends SnsMessageValidationException { + // JSON parsing or format errors +} + +public class SnsSignatureValidationException extends SnsMessageValidationException { + // Signature verification failures +} + +public class SnsCertificateException extends SnsMessageValidationException { + // Certificate retrieval or validation errors +} +``` + +### Error Scenarios +1. **Malformed JSON**: Clear parsing error with details +2. **Missing Required Fields**: Specific field validation errors +3. **Invalid Signature**: Cryptographic verification failure +4. **Certificate Issues**: Certificate retrieval or validation problems +5. **Invalid Certificate URL**: Security validation of certificate source + +## Testing Strategy + +### Unit Tests +- **SnsMessageParser**: JSON parsing, field extraction, format validation +- **SignatureValidator**: Cryptographic verification with known test vectors +- **CertificateRetriever**: Certificate fetching, caching, URL validation +- **DefaultSnsMessageManager**: Integration of all components + +### Integration Tests +- **Real SNS Messages**: Test with actual SNS message samples +- **Different Regions**: Validate messages from various AWS regions +- **Message Types**: Test all supported message types +- **Error Conditions**: Verify proper error handling + +### Test Data +- Sample SNS messages for each type (Notification, SubscriptionConfirmation, UnsubscribeConfirmation) +- Invalid messages for error testing +- Test certificates and signatures for validation testing + +## Implementation Considerations + +### Security +- Certificate URL validation against known AWS SNS-signed domains only +- HTTPS-only certificate retrieval to prevent interception attacks +- Proper certificate chain validation and Amazon SNS issuance verification +- Protection against certificate spoofing attacks +- Rejection of unexpected message fields or formats +- Never trusting certificates provided directly in messages without validation + +### Performance +- Certificate caching to avoid repeated HTTP requests +- Efficient JSON parsing +- Thread-safe implementation for concurrent usage + +### Compatibility +- Support for SignatureVersion1 (SHA1) and SignatureVersion2 (SHA256) as per AWS SNS standards +- Graceful handling of future signature version updates +- Consistent behavior across different AWS partitions +- API compatibility with AWS SDK v1 SnsMessageManager functionality + +### Dependencies +The implementation will require: +- JSON parsing (Jackson, already available in SDK) +- HTTP client for certificate retrieval (SDK's HTTP client) +- Cryptographic libraries (Java standard library) +- No additional external dependencies \ No newline at end of file diff --git a/pom.xml b/pom.xml index 851220ede747..0d6b59f49b09 100644 --- a/pom.xml +++ b/pom.xml @@ -630,6 +630,7 @@ s3-transfer-manager iam-policy-builder s3-event-notifications + sns-message-manager s3 diff --git a/services-custom/pom.xml b/services-custom/pom.xml index df17e6f099bb..f6bd32aa1bf9 100644 --- a/services-custom/pom.xml +++ b/services-custom/pom.xml @@ -32,6 +32,7 @@ s3-transfer-manager iam-policy-builder s3-event-notifications + sns-message-manager diff --git a/services-custom/sns-message-manager/pom.xml b/services-custom/sns-message-manager/pom.xml new file mode 100644 index 000000000000..bff6e9291e16 --- /dev/null +++ b/services-custom/sns-message-manager/pom.xml @@ -0,0 +1,140 @@ + + + + + 4.0.0 + + software.amazon.awssdk + aws-sdk-java-pom + 2.42.35-SNAPSHOT + ../../pom.xml + + sns-message-manager + AWS Java SDK :: SNS :: Message Manager + + The AWS SDK for Java - SNS Message Manager is a library for deserializing and verifying messages received from SNS. + + https://aws.amazon.com/sdkforjava + + 1.8 + ${project.parent.version} + + + + + + software.amazon.awssdk + bom-internal + ${awsjavasdk.version} + pom + import + + + + + + + software.amazon.awssdk + utils + ${project.version} + + + software.amazon.awssdk + json-utils + ${project.version} + + + software.amazon.awssdk + annotations + ${project.version} + + + software.amazon.awssdk + sdk-core + ${project.version} + + + org.junit.jupiter + junit-jupiter + test + + + software.amazon.awssdk + regions + ${project.version} + + + software.amazon.awssdk + sns + ${project.version} + + + software.amazon.awssdk + http-client-spi + ${project.version} + + + software.amazon.awssdk + endpoints-spi + ${project.version} + + + org.apache.httpcomponents.client5 + httpclient5 + ${httpcomponents.client5.version} + + + software.amazon.awssdk + apache5-client + ${project.version} + runtime + + + org.assertj + assertj-core + test + + + nl.jqno.equalsverifier + equalsverifier + test + + + mockito-core + org.mockito + test + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + software.amazon.awssdk.messagemanager.sns + + + + + + + + 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..8f255dbc3759 --- /dev/null +++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/SnsMessageManager.java @@ -0,0 +1,135 @@ +/* + * 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. + * + * @param messageStream The binary stream representation of the SNS message. + * @return The parsed SNS message. + */ + 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. + * + * @param messageContent The string representation of the SNS message. + * @return the parsed SNS message. + */ + 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. + * This is a required parameter. + * + * @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 new file mode 100644 index 000000000000..4720c2859767 --- /dev/null +++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateRetriever.java @@ -0,0 +1,168 @@ +/* + * 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.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.PublicKey; +import java.security.cert.CertificateException; +import java.security.cert.CertificateExpiredException; +import java.security.cert.CertificateFactory; +import java.security.cert.CertificateNotYetValidException; +import java.security.cert.X509Certificate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.net.ssl.SSLException; +import org.apache.hc.client5.http.ssl.DefaultHostnameVerifier; +import org.apache.hc.client5.http.ssl.HttpClientHostnameVerifier; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.http.HttpExecuteRequest; +import software.amazon.awssdk.http.HttpExecuteResponse; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpMethod; +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; + +/** + * Internal certificate retriever for SNS message validation. + *

+ * This class retrieves the certificate used to sign a message, validates it, and caches them for future use. + */ +@SdkInternalApi +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]*$")); + + private final HttpClientHostnameVerifier hostnameVerifier = new DefaultHostnameVerifier(); + + private final SdkHttpClient httpClient; + private final String certCommonName; + private final CertificateUrlValidator certUrlValidator; + private final LruCache certificateCache; + + 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::fetchCertificate) + .maxSize(10) + .build(); + this.certUrlValidator = Validate.paramNotNull(certificateUrlValidator, "certificateUrlValidator"); + } + + public PublicKey retrieveCertificate(URI certificateUrl) { + Validate.paramNotNull(certificateUrl, "certificateUrl"); + certUrlValidator.validate(certificateUrl); + return certificateCache.get(certificateUrl); + } + + @Override + public void close() { + httpClient.close(); + } + + private PublicKey fetchCertificate(URI certificateUrl) { + byte[] cert = fetchUrl(certificateUrl); + validateCertificateData(cert); + return createPublicKey(cert); + } + + private byte[] fetchUrl(URI certificateUrl) { + SdkHttpRequest httpRequest = SdkHttpRequest.builder() + .method(SdkHttpMethod.GET) + .uri(certificateUrl) + .build(); + + HttpExecuteRequest executeRequest = HttpExecuteRequest.builder() + .request(httpRequest) + .build(); + + try { + HttpExecuteResponse response = httpClient.prepareRequest(executeRequest).call(); + + if (!response.httpResponse().isSuccessful()) { + throw SdkClientException.create("Request was unsuccessful. Status Code: " + response.httpResponse().statusCode()); + } + + return readResponse(response); + } catch (SdkClientException e) { + throw e; + } catch (Exception e) { + throw SdkClientException.create("Unexpected error while retrieving URL: " + certificateUrl, e); + } + } + + private byte[] readResponse(HttpExecuteResponse response) throws IOException { + try (InputStream inputStream = response.responseBody().orElseThrow( + () -> SdkClientException.create("Response body is empty"))) { + + return IoUtils.toByteArray(inputStream); + } + } + + private PublicKey createPublicKey(byte[] cert) { + try { + CertificateFactory fact = CertificateFactory.getInstance("X.509"); + InputStream stream = new ByteArrayInputStream(cert); + X509Certificate cer = (X509Certificate) fact.generateCertificate(stream); + validateCertificate(cer, certCommonName); + return cer.getPublicKey(); + } catch (CertificateExpiredException e) { + throw SdkClientException.create("The certificate is expired", e); + } catch (CertificateNotYetValidException e) { + throw SdkClientException.create("The certificate is not yet valid", e); + } catch (CertificateException e) { + throw SdkClientException.create("The certificate could not be parsed", e); + } + } + + /** + * Check that the certificate is valid and that the principal is actually SNS. + */ + private void validateCertificate(X509Certificate cer, String expectedCommonName) throws CertificateExpiredException, + CertificateNotYetValidException { + verifyHostname(cer, expectedCommonName); + cer.checkValidity(); + } + + private void verifyHostname(X509Certificate cer, String expectedCertCommonName) { + try { + hostnameVerifier.verify(expectedCertCommonName, cer); + } catch (SSLException e) { + throw SdkClientException.create("Certificate does not match expected common name: " + + expectedCertCommonName, e); + } + } + + private void validateCertificateData(byte[] data) { + Matcher m = X509_FORMAT.getValue().matcher(new String(data, StandardCharsets.UTF_8)); + if (!m.matches()) { + throw SdkClientException.create("Certificate does not match expected X509 PEM format."); + } + } +} \ No newline at end of file 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 new file mode 100644 index 000000000000..cc5902506201 --- /dev/null +++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateUrlValidator.java @@ -0,0 +1,48 @@ +/* + * 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.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 certificateHost; + + public CertificateUrlValidator(String certificateHost) { + Validate.notBlank(certificateHost, "Expected certificate host cannot be null or empty"); + this.certificateHost = certificateHost; + } + + public void validate(URI certificateUrl) { + if (certificateUrl == null) { + throw SdkClientException.create("Certificate URL cannot be null"); + } + + if (!"https".equals(certificateUrl.getScheme())) { + throw SdkClientException.create("Certificate URL must use HTTPS"); + } + + 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/SignatureValidator.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/SignatureValidator.java new file mode 100644 index 000000000000..963e6901d930 --- /dev/null +++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/SignatureValidator.java @@ -0,0 +1,171 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.util.StringJoiner; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.messagemanager.sns.model.SignatureVersion; +import software.amazon.awssdk.messagemanager.sns.model.SnsMessage; +import software.amazon.awssdk.messagemanager.sns.model.SnsNotification; +import software.amazon.awssdk.messagemanager.sns.model.SnsSubscriptionConfirmation; +import software.amazon.awssdk.messagemanager.sns.model.SnsUnsubscribeConfirmation; +import software.amazon.awssdk.utils.Logger; +import software.amazon.awssdk.utils.Validate; + +/** + * See + * + * The official documentation. + */ +@SdkInternalApi +public final class SignatureValidator { + private static final Logger LOG = Logger.loggerFor(SignatureValidator.class); + + private static final String MESSAGE = "Message"; + private static final String MESSAGE_ID = "MessageId"; + private static final String SUBJECT = "Subject"; + private static final String SUBSCRIBE_URL = "SubscribeURL"; + private static final String TIMESTAMP = "Timestamp"; + private static final String TOKEN = "Token"; + private static final String TOPIC_ARN = "TopicArn"; + private static final String TYPE = "Type"; + + private static final String NEWLINE = "\n"; + + public void validateSignature(SnsMessage message, PublicKey publicKey) { + Validate.paramNotNull(message, "message"); + Validate.paramNotNull(publicKey, "publicKey"); + + SdkBytes messageSignature = message.signature(); + if (messageSignature == null) { + throw SdkClientException.create("Message signature cannot be null"); + } + + SignatureVersion signatureVersion = message.signatureVersion(); + if (signatureVersion == null) { + throw SdkClientException.create("Message signature version cannot be null"); + } + + if (message.timestamp() == null) { + throw SdkClientException.create("Message timestamp cannot be null"); + } + + String canonicalMessage = buildCanonicalMessage(message); + LOG.debug(() -> String.format("Canonical message: %s%n", canonicalMessage)); + + Signature signature = getSignature(signatureVersion); + + verifySignature(canonicalMessage, messageSignature, publicKey, signature); + } + + private static String buildCanonicalMessage(SnsMessage message) { + switch (message.type()) { + case NOTIFICATION: + return buildCanonicalMessage((SnsNotification) message); + case SUBSCRIPTION_CONFIRMATION: + return buildCanonicalMessage((SnsSubscriptionConfirmation) message); + case UNSUBSCRIBE_CONFIRMATION: + return buildCanonicalMessage((SnsUnsubscribeConfirmation) message); + default: + throw new IllegalStateException(String.format("Unsupported SNS message type: %s", message.type())); + } + } + + private static String buildCanonicalMessage(SnsNotification notification) { + StringJoiner joiner = new StringJoiner(NEWLINE, "", NEWLINE); + joiner.add(MESSAGE).add(notification.message()); + joiner.add(MESSAGE_ID).add(notification.messageId()); + + if (notification.subject() != null) { + joiner.add(SUBJECT).add(notification.subject()); + } + + joiner.add(TIMESTAMP).add(notification.timestamp().toString()); + joiner.add(TOPIC_ARN).add(notification.topicArn()); + joiner.add(TYPE).add(notification.type().toString()); + + return joiner.toString(); + } + + // Message, MessageId, SubscribeURL, Timestamp, Token, TopicArn, and Type. + private static String buildCanonicalMessage(SnsSubscriptionConfirmation message) { + StringJoiner joiner = new StringJoiner(NEWLINE, "", NEWLINE); + joiner.add(MESSAGE).add(message.message()); + joiner.add(MESSAGE_ID).add(message.messageId()); + joiner.add(SUBSCRIBE_URL).add(message.subscribeUrl().toString()); + joiner.add(TIMESTAMP).add(message.timestamp().toString()); + joiner.add(TOKEN).add(message.token()); + joiner.add(TOPIC_ARN).add(message.topicArn()); + joiner.add(TYPE).add(message.type().toString()); + + return joiner.toString(); + } + + private static String buildCanonicalMessage(SnsUnsubscribeConfirmation message) { + StringJoiner joiner = new StringJoiner(NEWLINE, "", NEWLINE); + joiner.add(MESSAGE).add(message.message()); + joiner.add(MESSAGE_ID).add(message.messageId()); + joiner.add(SUBSCRIBE_URL).add(message.subscribeUrl().toString()); + joiner.add(TIMESTAMP).add(message.timestamp().toString()); + joiner.add(TOKEN).add(message.token()); + joiner.add(TOPIC_ARN).add(message.topicArn()); + joiner.add(TYPE).add(message.type().toString()); + + return joiner.toString(); + } + + private static void verifySignature(String canonicalMessage, SdkBytes messageSignature, PublicKey publicKey, + Signature signature) { + + try { + signature.initVerify(publicKey); + signature.update(canonicalMessage.getBytes(StandardCharsets.UTF_8)); + + boolean isValid = signature.verify(messageSignature.asByteArray()); + + if (!isValid) { + throw SdkClientException.create("The computed signature did not match the expected signature"); + } + } catch (InvalidKeyException e) { + throw SdkClientException.create("The public key is invalid", e); + } catch (SignatureException e) { + throw SdkClientException.create("The signature is invalid", e); + } + } + + private static Signature getSignature(SignatureVersion signatureVersion) { + try { + switch (signatureVersion) { + case VERSION_1: + return Signature.getInstance("SHA1withRSA"); + case VERSION_2: + return Signature.getInstance("SHA256withRSA"); + default: + throw new IllegalArgumentException("Unsupported signature version: " + signatureVersion); + } + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Unable to create Signature for " + signatureVersion, e); + } + } +} \ No newline at end of file diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/SnsHostProvider.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/SnsHostProvider.java new file mode 100644 index 000000000000..afe5c41e620e --- /dev/null +++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/SnsHostProvider.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.internal; + +import java.net.URI; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.annotations.SdkTestInternalApi; +import software.amazon.awssdk.annotations.ThreadSafe; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.endpoints.Endpoint; +import software.amazon.awssdk.regions.PartitionMetadata; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.sns.endpoints.SnsEndpointParams; +import software.amazon.awssdk.services.sns.endpoints.SnsEndpointProvider; +import software.amazon.awssdk.utils.CompletableFutureUtils; +import software.amazon.awssdk.utils.Logger; +import software.amazon.awssdk.utils.Validate; + +/** + * Utility class for determining both the regional endpoint that SNS certificates are expected to be hosted from, as well as the + * expected common name (CN) that the certificate from that endpoint must have. + */ +@SdkInternalApi +@ThreadSafe +public class SnsHostProvider { + private static final Logger LOG = Logger.loggerFor(SnsHostProvider.class); + + private final Region region; + private final SnsEndpointProvider endpointProvider; + + public SnsHostProvider(Region region) { + this(region, SnsEndpointProvider.defaultProvider()); + } + + @SdkTestInternalApi + SnsHostProvider(Region region, SnsEndpointProvider endpointProvider) { + Validate.notNull(region, "region must not be null"); + Validate.notNull(endpointProvider, "endpointProvider must not be null"); + this.region = region; + this.endpointProvider = endpointProvider; + } + + public URI regionalEndpoint() { + SnsEndpointParams params = SnsEndpointParams.builder().region(region).build(); + try { + Endpoint endpoint = CompletableFutureUtils.joinLikeSync(endpointProvider.resolveEndpoint(params)); + URI url = endpoint.url(); + LOG.debug(() -> String.format("Resolved endpoint %s for region %s", url, region)); + return url; + } catch (SdkClientException e) { + throw SdkClientException.create("Unable to resolve SNS endpoint for region " + region, e); + } + } + + public String signingCertCommonName() { + String commonName = signingCertCommonNameInternal(); + LOG.debug(() -> String.format("Resolved common name %s for region %s", commonName, region)); + return commonName; + } + + private String signingCertCommonNameInternal() { + // If we don't know about this region, try to guess common name + if (!Region.regions().contains(region)) { + // Find the partition where it belongs by checking the region against the published pattern for known partitions. + // e.g. 'us-gov-west-3' would match the 'aws-us-gov' partition. + // This will return the 'aws' partition if it fails to match any partition. + PartitionMetadata partitionMetadata = PartitionMetadata.of(region); + return "sns." + partitionMetadata.dnsSuffix(); + } + + String regionId = region.id(); + + switch (regionId) { + case "cn-north-1": + return "sns-cn-north-1.amazonaws.com.cn"; + case "cn-northwest-1": + return "sns-cn-northwest-1.amazonaws.com.cn"; + case "us-gov-west-1": + case "us-gov-east-1": + return "sns-us-gov-west-1.amazonaws.com"; + case "us-iso-east-1": + return "sns-us-iso-east-1.c2s.ic.gov"; + case "us-isob-east-1": + return "sns-us-isob-east-1.sc2s.sgov.gov"; + case "us-isof-east-1": + return "sns-signing.us-isof-east-1.csp.hci.ic.gov"; + case "us-isof-south-1": + return "sns-signing.us-isof-south-1.csp.hci.ic.gov"; + case "eu-isoe-west-1": + return "sns-signing.eu-isoe-west-1.cloud.adc-e.uk"; + case "eusc-de-east-1": + return "sns-signing.eusc-de-east-1.amazonaws.eu"; + case "ap-east-1": + case "ap-east-2": + case "ap-south-2": + case "ap-southeast-5": + case "ap-southeast-6": + case "ap-southeast-7": + case "me-south-1": + case "me-central-1": + case "eu-south-1": + case "eu-south-2": + case "eu-central-2": + case "af-south-1": + case "ap-southeast-3": + case "ap-southeast-4": + case "il-central-1": + case "ca-west-1": + case "mx-central-1": + return "sns-signing." + regionId + ".amazonaws.com"; + default: + return "sns.amazonaws.com"; + } + } +} diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/SnsMessageUnmarshaller.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/SnsMessageUnmarshaller.java new file mode 100644 index 000000000000..c8b168e9f23d --- /dev/null +++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/internal/SnsMessageUnmarshaller.java @@ -0,0 +1,170 @@ +/* + * 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.InputStream; +import java.net.URI; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.Optional; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.messagemanager.sns.model.SignatureVersion; +import software.amazon.awssdk.messagemanager.sns.model.SnsMessage; +import software.amazon.awssdk.messagemanager.sns.model.SnsMessageType; +import software.amazon.awssdk.messagemanager.sns.model.SnsNotification; +import software.amazon.awssdk.messagemanager.sns.model.SnsSubscriptionConfirmation; +import software.amazon.awssdk.messagemanager.sns.model.SnsUnsubscribeConfirmation; +import software.amazon.awssdk.protocols.jsoncore.JsonNode; +import software.amazon.awssdk.protocols.jsoncore.JsonNodeParser; +import software.amazon.awssdk.utils.BinaryUtils; + +@SdkInternalApi +public class SnsMessageUnmarshaller { + private static final String MESSAGE_FIELD = "Message"; + private static final String MESSAGE_ID_FIELD = "MessageId"; + private static final String SIGNATURE_FIELD = "Signature"; + private static final String SIGNATURE_VERSION_FIELD = "SignatureVersion"; + private static final String SIGNING_CERT_URL = "SigningCertURL"; + private static final String SUBJECT_FIELD = "Subject"; + private static final String SUBSCRIBE_URL = "SubscribeURL"; + private static final String TOKEN_FIELD = "Token"; + private static final String TOPIC_ARN_FIELD = "TopicArn"; + private static final String TIMESTAMP_FIELD = "Timestamp"; + private static final String TYPE_FIELD = "Type"; + private static final String UNSUBSCRIBE_URL_FIELD = "UnsubscribeURL"; + + private static final JsonNodeParser PARSER = JsonNodeParser.builder() + .removeErrorLocations(true) + .build(); + + public SnsMessage unmarshall(InputStream stream) { + JsonNode node = PARSER.parse(stream); + + if (!node.isObject()) { + throw SdkClientException.create("Expected an JSON object"); + } + + Optional type = stringMember(node, TYPE_FIELD); + + if (!type.isPresent()) { + throw SdkClientException.create("'Type' field must be present"); + } + + SnsMessageType snsMessageType = SnsMessageType.fromValue(type.get()); + switch (snsMessageType) { + case NOTIFICATION: { + SnsNotification.Builder builder = SnsNotification.builder(); + unmarshallNotification(node, builder); + return builder.build(); + } + case SUBSCRIPTION_CONFIRMATION: { + SnsSubscriptionConfirmation.Builder builder = SnsSubscriptionConfirmation.builder(); + unmarshallSubscriptionConfirmation(node, builder); + return builder.build(); + } + case UNSUBSCRIBE_CONFIRMATION: { + SnsUnsubscribeConfirmation.Builder builder = SnsUnsubscribeConfirmation.builder(); + unmarshallUnsubscribeNotification(node, builder); + return builder.build(); + } + default: + throw SdkClientException.create("Unsupported sns message type: " + snsMessageType); + } + } + + private Optional stringMember(JsonNode obj, String fieldName) { + Optional memberOptional = obj.field(fieldName); + + if (!memberOptional.isPresent()) { + return Optional.empty(); + } + + JsonNode node = memberOptional.get(); + if (!node.isString()) { + String msg = String.format("Expected field '%s' to be a string", fieldName); + throw SdkClientException.create(msg); + } + + return Optional.of(node.asString()); + } + + private void unmarshallNotification(JsonNode node, SnsNotification.Builder builder) { + stringMember(node, SUBJECT_FIELD).ifPresent(builder::subject); + stringMember(node, UNSUBSCRIBE_URL_FIELD) + .map(SnsMessageUnmarshaller::toUri) + .ifPresent(builder::unsubscribeUrl); + + unmarshallCommon(node, builder); + } + + // https://docs.aws.amazon.com/sns/latest/dg/http-subscription-confirmation-json.html + private void unmarshallSubscriptionConfirmation(JsonNode node, SnsSubscriptionConfirmation.Builder builder) { + stringMember(node, TOKEN_FIELD).ifPresent(builder::token); + stringMember(node, SUBSCRIBE_URL).map(SnsMessageUnmarshaller::toUri).ifPresent(builder::subscribeUrl); + unmarshallCommon(node, builder); + } + + // https://docs.aws.amazon.com/sns/latest/dg/http-unsubscribe-confirmation-json.html + private void unmarshallUnsubscribeNotification(JsonNode node, SnsUnsubscribeConfirmation.Builder builder) { + stringMember(node, TOKEN_FIELD).ifPresent(builder::token); + stringMember(node, SUBSCRIBE_URL).map(SnsMessageUnmarshaller::toUri).ifPresent(builder::subscribeUrl); + unmarshallCommon(node, builder); + } + + private void unmarshallCommon(JsonNode node, SnsMessage.Builder builder) { + stringMember(node, MESSAGE_ID_FIELD).ifPresent(builder::messageId); + stringMember(node, MESSAGE_FIELD).ifPresent(builder::message); + stringMember(node, TOPIC_ARN_FIELD).ifPresent(builder::topicArn); + + Optional timestamp = stringMember(node, TIMESTAMP_FIELD); + if (timestamp.isPresent()) { + try { + Instant instant = Instant.parse(timestamp.get()); + builder.timestamp(instant); + } catch (DateTimeParseException e) { + throw SdkClientException.create("Unable to parse timestamp", e); + } + } + + Optional signatureField = stringMember(node, SIGNATURE_FIELD); + if (signatureField.isPresent()) { + try { + byte[] decoded = BinaryUtils.fromBase64(signatureField.get()); + builder.signature(SdkBytes.fromByteArray(decoded)); + } catch (IllegalArgumentException e) { + throw SdkClientException.create("Unable to decode signature", e); + } + } + + stringMember(node, SIGNATURE_VERSION_FIELD) + .map(SignatureVersion::fromValue) + .ifPresent(builder::signatureVersion); + + stringMember(node, SIGNING_CERT_URL) + .map(SnsMessageUnmarshaller::toUri) + .ifPresent(builder::signingCertUrl); + } + + private static URI toUri(String s) { + try { + return URI.create(s); + } catch (IllegalArgumentException e) { + throw SdkClientException.create("Unable to parse URI", e); + } + } +} \ No newline at end of file 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 new file mode 100644 index 000000000000..971ad09975b8 --- /dev/null +++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SignatureVersion.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.model; + +import java.util.Objects; +import software.amazon.awssdk.annotations.SdkPublicApi; + +/** + * The signature version used to sign an SNS message. + */ +@SdkPublicApi +public enum SignatureVersion { + VERSION_1("1"), + VERSION_2("2"), + UNKNOWN(null) + ; + + private final String value; + + SignatureVersion(String value) { + this.value = value; + } + + @Override + public String toString() { + return value; + } + + public static SignatureVersion fromValue(String value) { + for (SignatureVersion v : values()) { + if (Objects.equals(v.value, value)) { + return v; + } + } + + return UNKNOWN; + } +} diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsMessage.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsMessage.java new file mode 100644 index 000000000000..b56ba151a3ee --- /dev/null +++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsMessage.java @@ -0,0 +1,238 @@ +/* + * 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.model; + +import java.net.URI; +import java.time.Instant; +import java.util.Objects; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.utils.ToString; + +/** + * Base class for SNS message types. This contains the common fields of SNS messages. + */ +@SdkPublicApi +public abstract class SnsMessage { + private final String messageId; + private final String message; + private final String topicArn; + private final Instant timestamp; + private final SdkBytes signature; + private final SignatureVersion signatureVersion; + private final URI signingCertUrl; + + protected SnsMessage(BuilderImpl builder) { + this.messageId = builder.messageId; + this.message = builder.message; + this.topicArn = builder.topicArn; + this.timestamp = builder.timestamp; + this.signature = builder.signature; + this.signatureVersion = builder.signatureVersion; + this.signingCertUrl = builder.signingCertUrl; + } + + /** + * The type of this message. + */ + public abstract SnsMessageType type(); + + /** + * A Universally Unique Identifier (UUID), unique for each message published. For a message that Amazon SNS resends during a + * retry, the message ID of the original message is used. + */ + public String messageId() { + return messageId; + } + + /** + * The message body. + */ + public String message() { + return message; + } + + /** + * The Amazon Resource Name (ARN) for the topic. + */ + public String topicArn() { + return topicArn; + } + + /** + * The time (GMT) when the message was sent. + */ + public Instant timestamp() { + return timestamp; + } + + /** + * SHA1withRSA or SHA256withRSA signature of this message. The values from the message used to calculate the signature are + * dictated by the message type. See + * the service documentation + * for more information. + */ + public SdkBytes signature() { + return signature; + } + + /** + * Version of the Amazon SNS signature used. + */ + public SignatureVersion signatureVersion() { + return signatureVersion; + } + + /** + * The URL to the certificate that was used to sign the message. + */ + public URI signingCertUrl() { + return signingCertUrl; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + + SnsMessage that = (SnsMessage) o; + return Objects.equals(messageId, that.messageId) + && Objects.equals(message, that.message) + && Objects.equals(topicArn, that.topicArn) + && Objects.equals(timestamp, that.timestamp) + && Objects.equals(signature, that.signature) + && signatureVersion == that.signatureVersion + && Objects.equals(signingCertUrl, that.signingCertUrl); + } + + @Override + public int hashCode() { + int result = Objects.hashCode(messageId); + result = 31 * result + Objects.hashCode(message); + result = 31 * result + Objects.hashCode(topicArn); + result = 31 * result + Objects.hashCode(timestamp); + result = 31 * result + Objects.hashCode(signature); + result = 31 * result + Objects.hashCode(signatureVersion); + result = 31 * result + Objects.hashCode(signingCertUrl); + return result; + } + + protected ToString toStringBuilder(String className) { + return ToString.builder(className) + .add("MessageId", messageId()) + .add("Message", message()) + .add("TopicArn", topicArn()) + .add("Timestamp", timestamp()) + .add("Signature", signature()) + .add("SignatureVersion", signatureVersion()) + .add("SigningCertUrl", signingCertUrl()); + } + + public interface Builder> { + /** + * A Universally Unique Identifier (UUID), unique for each message published. For a message that Amazon SNS resends during + * a retry, the message ID of the original message is used. + */ + SubclassT messageId(String messageId); + + /** + * The message body. + */ + SubclassT message(String message); + + /** + * The Amazon Resource Name (ARN) for the topic. + */ + SubclassT topicArn(String topicArn); + + /** + * The time (GMT) when the message was sent. + */ + SubclassT timestamp(Instant timestamp); + + /** + * SHA1withRSA or SHA256withRSA signature of this message. The values from the message used to calculate the signature are + * dictated by the message type. See + * the service documentation + * for more information. + */ + SubclassT signature(SdkBytes signature); + + /** + * Version of the Amazon SNS signature used. + */ + SubclassT signatureVersion(SignatureVersion signatureVersion); + + /** + * The URL to the certificate that was used to sign the message. + */ + SubclassT signingCertUrl(URI signingCertUrl); + } + + @SuppressWarnings("unchecked") + protected static class BuilderImpl> implements Builder { + private String messageId; + private String message; + private String topicArn; + private Instant timestamp; + private SdkBytes signature; + private SignatureVersion signatureVersion; + private URI signingCertUrl; + + @Override + public SubclassT message(String message) { + this.message = message; + return (SubclassT) this; + } + + @Override + public SubclassT messageId(String messageId) { + this.messageId = messageId; + return (SubclassT) this; + } + + @Override + public SubclassT topicArn(String topicArn) { + this.topicArn = topicArn; + return (SubclassT) this; + } + + @Override + public SubclassT timestamp(Instant timestamp) { + this.timestamp = timestamp; + return (SubclassT) this; + } + + @Override + public SubclassT signature(SdkBytes signature) { + this.signature = signature; + return (SubclassT) this; + } + + @Override + public SubclassT signatureVersion(SignatureVersion signatureVersion) { + this.signatureVersion = signatureVersion; + return (SubclassT) this; + } + + @Override + public SubclassT signingCertUrl(URI signingCertUrl) { + this.signingCertUrl = signingCertUrl; + return (SubclassT) this; + } + } +} \ No newline at end of file diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsMessageType.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsMessageType.java new file mode 100644 index 000000000000..e0ca27f5cfb1 --- /dev/null +++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsMessageType.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.model; + +import software.amazon.awssdk.annotations.SdkPublicApi; + +/** + * The type of message sent by SNS. This corresponds to the value of the {@code Type} field in the SNS message. See the + * SNS developer guide + * for more information. + */ +@SdkPublicApi +public enum SnsMessageType { + SUBSCRIPTION_CONFIRMATION("SubscriptionConfirmation"), + NOTIFICATION("Notification"), + UNSUBSCRIBE_CONFIRMATION("UnsubscribeConfirmation"), + + /** + * The type of the SNS message is unknown to this SDK version. + */ + UNKNOWN("Unknown"); + + private final String value; + + SnsMessageType(String value) { + this.value = value; + } + + public static SnsMessageType fromValue(String value) { + for (SnsMessageType type : values()) { + if (type.value.equalsIgnoreCase(value)) { + return type; + } + } + return UNKNOWN; + } + + + @Override + public String toString() { + return value; + } +} diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsNotification.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsNotification.java new file mode 100644 index 000000000000..8ba35fc93ce0 --- /dev/null +++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsNotification.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.model; + +import java.net.URI; +import java.util.Objects; +import software.amazon.awssdk.annotations.SdkPublicApi; + +/** + * A 'Notification' message from SNS. + *

+ * See the API reference for more + * information. + */ +@SdkPublicApi +public final class SnsNotification extends SnsMessage { + private final String subject; + private final URI unsubscribeUrl; + + SnsNotification(BuilderImpl builder) { + super(builder); + this.subject = builder.subject; + this.unsubscribeUrl = builder.unsubscribeUrl; + } + + @Override + public SnsMessageType type() { + return SnsMessageType.NOTIFICATION; + } + + /** + * The subject of the message. This may be {@code null}. + */ + public String subject() { + return subject; + } + + /** + * The URL that can be visited to unsubscribe from the topic. + */ + public URI unsubscribeUrl() { + return unsubscribeUrl; + } + + public static Builder builder() { + return new BuilderImpl(); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + if (!super.equals(o)) { + return false; + } + + SnsNotification that = (SnsNotification) o; + return Objects.equals(subject, that.subject) + && Objects.equals(unsubscribeUrl, that.unsubscribeUrl); + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + Objects.hashCode(subject); + result = 31 * result + Objects.hashCode(unsubscribeUrl); + return result; + } + + @Override + public String toString() { + return toStringBuilder("SnsNotification") + .add("Subject", subject()) + .add("UnsubscribeUrl", unsubscribeUrl()) + .build(); + } + + public interface Builder extends SnsMessage.Builder { + + /** + * The subject of the message. This may be {@code null}. + */ + Builder subject(String subject); + + /** + * The URL that can be visited to unsubscribe from the topic. + */ + Builder unsubscribeUrl(URI unsubscribeUrl); + + SnsNotification build(); + } + + private static class BuilderImpl extends SnsMessage.BuilderImpl implements Builder { + private String subject; + private URI unsubscribeUrl; + + @Override + public Builder subject(String subject) { + this.subject = subject; + return this; + } + + @Override + public Builder unsubscribeUrl(URI unsubscribeUrl) { + this.unsubscribeUrl = unsubscribeUrl; + return this; + } + + @Override + public SnsNotification build() { + return new SnsNotification(this); + } + } +} diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsSubscriptionConfirmation.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsSubscriptionConfirmation.java new file mode 100644 index 000000000000..697f94c88d79 --- /dev/null +++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsSubscriptionConfirmation.java @@ -0,0 +1,132 @@ +/* + * 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.model; + +import java.net.URI; +import java.util.Objects; +import software.amazon.awssdk.annotations.SdkPublicApi; + +/** + * A 'SubscriptionConfirmation' message from SNS. + *

+ * See the API reference for more + * information. + */ +@SdkPublicApi +public final class SnsSubscriptionConfirmation extends SnsMessage { + private final URI subscribeUrl; + private final String token; + + private SnsSubscriptionConfirmation(BuilderImpl builder) { + super(builder); + this.subscribeUrl = builder.subscribeUrl; + this.token = builder.token; + } + + @Override + public SnsMessageType type() { + return SnsMessageType.SUBSCRIPTION_CONFIRMATION; + } + + /** + * The URL that can be visited to confirm subscription to the topic. + */ + public URI subscribeUrl() { + return subscribeUrl; + } + + /** + * A value that can be used with the + * ConfirmSubscription + * API to confirm subscription to the topic. + */ + public String token() { + return token; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + if (!super.equals(o)) { + return false; + } + + SnsSubscriptionConfirmation that = (SnsSubscriptionConfirmation) o; + return Objects.equals(subscribeUrl, that.subscribeUrl) + && Objects.equals(token, that.token); + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + Objects.hashCode(subscribeUrl); + result = 31 * result + Objects.hashCode(token); + return result; + } + + @Override + public String toString() { + return toStringBuilder("SnsSubscriptionConfirmation") + .add("SubscribeUrl", subscribeUrl()) + .add("Token", token()) + .build(); + } + + public static Builder builder() { + return new BuilderImpl(); + } + + public interface Builder extends SnsMessage.Builder { + + /** + * The URL that can be visited to confirm subscription to the topic. + */ + Builder subscribeUrl(URI subscribeUrl); + + /** + * A value that can be used with the + * ConfirmSubscription + * API to confirm subscription to the topic. + */ + Builder token(String token); + + SnsSubscriptionConfirmation build(); + } + + private static class BuilderImpl extends SnsMessage.BuilderImpl implements Builder { + private URI subscribeUrl; + private String token; + + @Override + public Builder subscribeUrl(URI subscribeUrl) { + this.subscribeUrl = subscribeUrl; + return this; + } + + @Override + public Builder token(String token) { + this.token = token; + return this; + } + + @Override + public SnsSubscriptionConfirmation build() { + return new SnsSubscriptionConfirmation(this); + } + } +} diff --git a/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsUnsubscribeConfirmation.java b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsUnsubscribeConfirmation.java new file mode 100644 index 000000000000..3f0dbd7b4584 --- /dev/null +++ b/services-custom/sns-message-manager/src/main/java/software/amazon/awssdk/messagemanager/sns/model/SnsUnsubscribeConfirmation.java @@ -0,0 +1,131 @@ +/* + * 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.model; + +import java.net.URI; +import java.util.Objects; +import software.amazon.awssdk.annotations.SdkPublicApi; + +/** + * An 'UnsubscribeConfirmation' message from SNS. + *

+ * See the API reference for more + * information. + */ +@SdkPublicApi +public final class SnsUnsubscribeConfirmation extends SnsMessage { + private final URI subscribeUrl; + private final String token; + + private SnsUnsubscribeConfirmation(BuilderImpl builder) { + super(builder); + this.subscribeUrl = builder.subscribeUrl; + this.token = builder.token; + } + + @Override + public SnsMessageType type() { + return SnsMessageType.UNSUBSCRIBE_CONFIRMATION; + } + + /** + * The URL that can be visited used to re-confirm subscription to the topic. + */ + public URI subscribeUrl() { + return subscribeUrl; + } + + /** + * A value that can be used with the + * ConfirmSubscription + * API to reconfirm subscription to the topic. + */ + public String token() { + return token; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + if (!super.equals(o)) { + return false; + } + + SnsUnsubscribeConfirmation that = (SnsUnsubscribeConfirmation) o; + return Objects.equals(subscribeUrl, that.subscribeUrl) && Objects.equals(token, that.token); + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + Objects.hashCode(subscribeUrl); + result = 31 * result + Objects.hashCode(token); + return result; + } + + @Override + public String toString() { + return toStringBuilder("SnsUnsubscribeConfirmation") + .add("SubscribeUrl", subscribeUrl()) + .add("Token", token()) + .build(); + } + + public static Builder builder() { + return new BuilderImpl(); + } + + public interface Builder extends SnsMessage.Builder { + + /** + * The URL that can be visited used to re-confirm subscription to the topic. + */ + Builder subscribeUrl(URI subscribeUrl); + + /** + * A value that can be used with the + * ConfirmSubscription + * API to reconfirm subscription to the topic. + */ + Builder token(String token); + + SnsUnsubscribeConfirmation build(); + } + + private static class BuilderImpl extends SnsMessage.BuilderImpl implements Builder { + private URI subscribeUrl; + private String token; + + @Override + public Builder subscribeUrl(URI subscribeUrl) { + this.subscribeUrl = subscribeUrl; + return this; + } + + @Override + public Builder token(String token) { + this.token = token; + return this; + } + + @Override + public SnsUnsubscribeConfirmation build() { + return new SnsUnsubscribeConfirmation(this); + } + } +} 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 new file mode 100644 index 000000000000..bc31f5def15a --- /dev/null +++ b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateRetrieverTest.java @@ -0,0 +1,355 @@ +/* + * 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.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.http.AbortableInputStream; +import software.amazon.awssdk.http.ExecutableHttpRequest; +import software.amazon.awssdk.http.HttpExecuteRequest; +import software.amazon.awssdk.http.HttpExecuteResponse; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpFullResponse; +import software.amazon.awssdk.utils.IoUtils; + +class CertificateRetrieverTest { + private static final String RESOURCE_ROOT = "/software/amazon/awssdk/messagemanager/sns/internal/"; + private static final String CERT_COMMON_NAME = "my-test-service.amazonaws.com"; + private static final URI TEST_CERT_URI = URI.create("https://my-test-service.amazonaws.com/cert.pem"); + private SdkHttpClient mockHttpClient; + + private static byte[] validCert; + private static byte[] expiredCert; + private static byte[] futureValidCert; + + @BeforeAll + static void classSetUp() throws IOException { + validCert = getResourceBytes("valid-cert.pem"); + expiredCert = getResourceBytes("expired-cert.pem"); + futureValidCert = getResourceBytes("valid-in-future-cert.pem"); + } + + @BeforeEach + 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, 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, 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, TEST_CERT_URI.getHost(), CERT_COMMON_NAME) + .retrieveCertificate(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("certificateUrl must not be null."); + } + + @Test + void retrieveCertificate_httpUrl_throwsException() { + 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"); + } + + @Test + void retrieveCertificate_httpError_throwsException() throws IOException { + mockResponse(SdkHttpFullResponse.builder().statusCode(400).build(), null); + + CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), + CERT_COMMON_NAME); + + assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(TEST_CERT_URI)) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("Request was unsuccessful. Status Code: 400"); + } + + @Test + void retrieveCertificate_callThrows_throwsException() throws IOException { + ExecutableHttpRequest mockExecRequest = mock(ExecutableHttpRequest.class); + + RuntimeException cause = new RuntimeException("oops"); + when(mockExecRequest.call()).thenThrow(cause); + + when(mockHttpClient.prepareRequest(any(HttpExecuteRequest.class))).thenReturn(mockExecRequest); + + CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), + CERT_COMMON_NAME); + + assertThatThrownBy(() -> certificateRetriever + .retrieveCertificate(TEST_CERT_URI)) + .isInstanceOf(SdkClientException.class) + .hasCause(cause) + .hasMessageContaining("Unexpected error while retrieving URL"); + } + + @Test + void retrieveCertificate_noResponseStream_throwsException() throws IOException { + mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), null); + + CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), + CERT_COMMON_NAME); + + assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(TEST_CERT_URI)) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("Response body is empty"); + } + + @Test + void retrieveCertificate_emptyResponseBody_throwsException() throws IOException { + mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), new byte[0]); + + CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), + CERT_COMMON_NAME); + + assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(TEST_CERT_URI)) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("Certificate does not match expected X509 PEM format."); + } + + @Test + 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, TEST_CERT_URI.getHost(), + CERT_COMMON_NAME); + + assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(TEST_CERT_URI)) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("Certificate does not match expected X509 PEM format."); + } + + @Test + void retrieveCertificate_nonParsableCertificate_throwsException() throws IOException { + String certificate = "-----BEGIN CERTIFICATE-----\n" + + "MIIE+DCCAuCgAwIBAgIJAOCC5W/Vl4AEMA0GCSqGSIb3DQEBDAUAMCgxJjAkBgNV\n" + + "-----END CERTIFICATE-----\n"; + mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), certificate.getBytes(StandardCharsets.UTF_8)); + + CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), + CERT_COMMON_NAME); + + assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(TEST_CERT_URI)) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("The certificate could not be parsed"); + } + + @Test + void retrieveCertificate_certificateExpired_throwsException() throws IOException { + mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), expiredCert); + CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), + CERT_COMMON_NAME); + + assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(TEST_CERT_URI)) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("The certificate is expired"); + } + + @Test + void retrieveCertificate_certNotYetValid_throwsException() throws IOException { + mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), futureValidCert); + CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), + CERT_COMMON_NAME); + + assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(TEST_CERT_URI)) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("The certificate is not yet valid"); + } + + @Test + 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, commonName); + + assertThatThrownBy(() -> certificateRetriever.retrieveCertificate(certUri)) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("Certificate does not match expected common name: my-other-service.amazonaws.com"); + } + + @Test + void retrieveCertificate_validPemCertificate_succeeds() throws IOException { + mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), validCert); + + CertificateRetriever certificateRetriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), + CERT_COMMON_NAME); + + 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, TEST_CERT_URI.getHost(), + CERT_COMMON_NAME); + + certificateRetriever.retrieveCertificate(TEST_CERT_URI); + certificateRetriever.retrieveCertificate(TEST_CERT_URI); + + verify(mockHttpClient, times(1)).prepareRequest(any(HttpExecuteRequest.class)); + } + + @Test + void retrieveCertificate_differentUrls_cachesIndependently() throws IOException { + mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), validCert); + + 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); + certificateRetriever.retrieveCertificate(cert1Url); + + URI cert2Url = URI.create("https://" + CERT_COMMON_NAME + "/cert2.pem"); + certificateRetriever.retrieveCertificate(cert2Url); + certificateRetriever.retrieveCertificate(cert2Url); + + verify(mockHttpClient, times(2)).prepareRequest(any(HttpExecuteRequest.class)); + + } + + @Test + void retrieveCertificate_concurrentAccess_threadSafe() throws Exception { + int threads = 4; + ExecutorService exec = Executors.newFixedThreadPool(threads); + + + for (int i = 0; i < 10_000; ++i) { + CountDownLatch start = new CountDownLatch(threads); + CountDownLatch end = new CountDownLatch(threads); + + mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), validCert); + + CertificateRetriever retriever = new CertificateRetriever(mockHttpClient, TEST_CERT_URI.getHost(), CERT_COMMON_NAME); + for (int j = 0; j < threads; ++j) { + exec.submit(() -> { + start.countDown(); + + retriever.retrieveCertificate(TEST_CERT_URI); + + end.countDown(); + }); + } + + end.await(); + + verify(mockHttpClient, times(1)).prepareRequest(any(HttpExecuteRequest.class)); + reset(mockHttpClient); + } + } + + @Test + void retrieveCertificate_concurrentDifferentUrls_threadSafe() throws Exception { + int threads = 4; + ExecutorService exec = Executors.newFixedThreadPool(threads); + + + for (int i = 0; i < 10_000; ++i) { + CountDownLatch start = new CountDownLatch(threads); + CountDownLatch end = new CountDownLatch(threads); + + mockResponse(SdkHttpFullResponse.builder().statusCode(200).build(), validCert); + + 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(() -> { + start.countDown(); + + retriever.retrieveCertificate(uri); + + end.countDown(); + }); + } + + end.await(); + + verify(mockHttpClient, times(2)).prepareRequest(any(HttpExecuteRequest.class)); + reset(mockHttpClient); + } + } + + private static byte[] getResourceBytes(String resourcePath) throws IOException { + return IoUtils.toByteArray(CertificateRetrieverTest.class.getResourceAsStream(RESOURCE_ROOT + resourcePath)); + } + + private void mockResponse(SdkHttpFullResponse httpResponse, byte[] content) throws IOException { + ExecutableHttpRequest mockExecRequest = mock(ExecutableHttpRequest.class); + + when(mockExecRequest.call()).thenAnswer(i -> { + AbortableInputStream body = null; + if (content != null) { + body = asStream(content); + } + + return HttpExecuteResponse.builder() + .response(httpResponse) + .responseBody(body) + .build(); + }); + + when(mockHttpClient.prepareRequest(any(HttpExecuteRequest.class))).thenReturn(mockExecRequest); + } + + private static AbortableInputStream asStream(byte[] b) { + return AbortableInputStream.create(new ByteArrayInputStream(b)); + } +} 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..dba83c8d8e20 --- /dev/null +++ b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/CertificateUrlValidatorTest.java @@ -0,0 +1,58 @@ +/* + * 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.assertThatNoException; +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_urlValid_succeeds() { + CertificateUrlValidator validator = new CertificateUrlValidator(CERT_HOST); + assertThatNoException().isThrownBy(() -> validator.validate(URI.create("https://" + CERT_HOST))); + } + + @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/SignatureValidatorTest.java b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/SignatureValidatorTest.java new file mode 100644 index 000000000000..fe30a03d16a1 --- /dev/null +++ b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/SignatureValidatorTest.java @@ -0,0 +1,199 @@ +/* + * 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 static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.InputStream; +import java.net.URI; +import java.security.PublicKey; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.messagemanager.sns.model.SignatureVersion; +import software.amazon.awssdk.messagemanager.sns.model.SnsMessage; +import software.amazon.awssdk.messagemanager.sns.model.SnsNotification; + +class SignatureValidatorTest { + private static final String RESOURCE_ROOT = "/software/amazon/awssdk/messagemanager/sns/internal/"; + private static final String SIGNING_CERT_RESOURCE = "SimpleNotificationService-7506a1e35b36ef5a444dd1a8e7cc3ed8.pem"; + private static final SignatureValidator VALIDATOR = new SignatureValidator(); + private static X509Certificate signingCertificate; + + @BeforeAll + static void setup() throws CertificateException { + InputStream is = resourceAsStream(SIGNING_CERT_RESOURCE); + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + signingCertificate = (X509Certificate) factory.generateCertificate(is); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("validMessages") + void validateSignature_signatureValid_doesNotThrow(TestCase tc) { + SnsMessageUnmarshaller unmarshaller = new SnsMessageUnmarshaller(); + SnsMessage msg = unmarshaller.unmarshall(resourceAsStream(tc.messageJsonResource)); + VALIDATOR.validateSignature(msg, signingCertificate.getPublicKey()); + } + + @Test + void validateSignature_signatureMismatch_throws() { + SnsNotification notification = SnsNotification.builder() + .message("hello world") + .messageId("message-id") + .timestamp(Instant.now()) + .signature(SdkBytes.fromByteArray(new byte[256])) + .signatureVersion(SignatureVersion.VERSION_1) + .build(); + + assertThatThrownBy(() -> VALIDATOR.validateSignature(notification, signingCertificate.getPublicKey())) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("The computed signature did not match the expected signature"); + } + + @Test + void validateSignature_signatureMissing_throws() { + SnsNotification notification = SnsNotification.builder() + .subject("hello world") + .message("hello world") + .messageId("message-id") + .timestamp(Instant.now()) + .unsubscribeUrl(URI.create("https://my-test-service.amazonaws.com")) + .signingCertUrl(URI.create("https://my-test-service.amazonaws.com/cert" + + ".pem")) + .signatureVersion(SignatureVersion.VERSION_1) + .build(); + + assertThatThrownBy(() -> VALIDATOR.validateSignature(notification, signingCertificate.getPublicKey())) + .isInstanceOf(SdkClientException.class) + .hasMessage("Message signature cannot be null"); + } + + @Test + void validateSignature_timestampMissing_throws() { + SnsNotification notification = SnsNotification.builder() + .subject("hello world") + .message("hello world") + .messageId("message-id") + .signature(SdkBytes.fromByteArray(new byte[256])) + .unsubscribeUrl(URI.create("https://my-test-service.amazonaws.com")) + .signingCertUrl(URI.create("https://my-test-service.amazonaws.com/cert" + + ".pem")) + .signatureVersion(SignatureVersion.VERSION_1) + .build(); + + assertThatThrownBy(() -> VALIDATOR.validateSignature(notification, signingCertificate.getPublicKey())) + .isInstanceOf(SdkClientException.class) + .hasMessage("Message timestamp cannot be null"); + } + + @Test + void validateSignature_signatureVersionMissing_throws() { + SnsNotification notification = SnsNotification.builder() + .subject("hello world") + .message("hello world") + .messageId("message-id") + .signature(SdkBytes.fromByteArray(new byte[256])) + .timestamp(Instant.now()) + .unsubscribeUrl(URI.create("https://my-test-service.amazonaws.com")) + .signingCertUrl(URI.create("https://my-test-service.amazonaws.com/cert" + + ".pem")) + .build(); + + + assertThatThrownBy(() -> VALIDATOR.validateSignature(notification, signingCertificate.getPublicKey())) + .isInstanceOf(SdkClientException.class) + .hasMessage("Message signature version cannot be null"); + } + + @Test + void validateSignature_certInvalid_throws() throws CertificateException { + SnsNotification notification = SnsNotification.builder() + .signature(SdkBytes.fromByteArray(new byte[1])) + .signatureVersion(SignatureVersion.VERSION_1) + .timestamp(Instant.now()) + .build(); + + PublicKey badKey = mock(PublicKey.class); + when(badKey.getFormat()).thenReturn("X.509"); + when(badKey.getAlgorithm()).thenReturn("RSA"); + when(badKey.getEncoded()).thenReturn(new byte[1]); + + assertThatThrownBy(() -> VALIDATOR.validateSignature(notification, badKey)) + .isInstanceOf(SdkClientException.class) + .hasMessage("The public key is invalid"); + } + + @Test + void validateSignature_signatureInvalid_throws() throws CertificateException { + SnsNotification notification = SnsNotification.builder() + .subject("hello world") + .message("hello world") + .messageId("message-id") + .signature(SdkBytes.fromByteArray(new byte[1])) + .signatureVersion(SignatureVersion.VERSION_1) + .timestamp(Instant.now()) + .unsubscribeUrl(URI.create("https://my-test-service.amazonaws.com")) + .signingCertUrl(URI.create("https://my-test-service.amazonaws.com/cert" + + ".pem")) + .build(); + + assertThatThrownBy(() -> VALIDATOR.validateSignature(notification, signingCertificate.getPublicKey())) + .isInstanceOf(SdkClientException.class) + .hasMessage("The signature is invalid"); + } + + private static List validMessages() { + return Stream.of( + new TestCase("Notification - No Subject", "test-notification-no-subject.json"), + new TestCase("Notification - Version 2 signature", "test-notification-signature-v2.json"), + new TestCase("Notification with subject", "test-notification-with-subject.json"), + new TestCase("Subscription confirmation", "test-subscription-confirmation.json"), + new TestCase("Unsubscribe confirmation", "test-unsubscribe-confirmation.json") + ) + .collect(Collectors.toList()); + } + + private static InputStream resourceAsStream(String resourceName) { + return SignatureValidatorTest.class.getResourceAsStream(RESOURCE_ROOT + resourceName); + } + + private static class TestCase { + private String desription; + private String messageJsonResource; + + public TestCase(String desription, String messageJsonResource) { + this.desription = desription; + this.messageJsonResource = messageJsonResource; + } + + @Override + public String toString() { + return desription; + } + } +} \ No newline at end of file diff --git a/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/SnsHostProviderTest.java b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/SnsHostProviderTest.java new file mode 100644 index 000000000000..95590e0fecaa --- /dev/null +++ b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/SnsHostProviderTest.java @@ -0,0 +1,129 @@ +/* + * 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.when; + +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.sns.endpoints.SnsEndpointParams; +import software.amazon.awssdk.services.sns.endpoints.SnsEndpointProvider; + +public class SnsHostProviderTest { + + @ParameterizedTest + @MethodSource("commonNameTestCases") + void signingCertCommonName_returnsCorrectNameForRegion(CommonNameTestCase tc) { + SnsHostProvider hostProvider = new SnsHostProvider(Region.of(tc.region)); + assertThat(hostProvider.signingCertCommonName()).isEqualTo(tc.expectedCommonName); + } + + @Test + void ctor_regionNull_throws() { + assertThatThrownBy(() -> new SnsHostProvider(null)).hasMessage("region must not be null"); + } + + @Test + void ctor_endpointProviderNull_throws() { + assertThatThrownBy(() -> new SnsHostProvider(Region.US_WEST_2, null)) + .hasMessage("endpointProvider must not be null"); + } + + @Test + void regionalEndpoint_delegatesToEndpointProvider() { + SnsEndpointProvider mockProvider = mock(SnsEndpointProvider.class); + SnsEndpointProvider realProvider = SnsEndpointProvider.defaultProvider(); + + when(mockProvider.resolveEndpoint(any(SnsEndpointParams.class))).thenAnswer( + i -> realProvider.resolveEndpoint(i.getArgument(0, SnsEndpointParams.class))); + + ArgumentCaptor paramsCaptor = ArgumentCaptor.forClass(SnsEndpointParams.class); + + Region region = Region.US_WEST_2; + SnsHostProvider hostProvider = new SnsHostProvider(region, mockProvider); + hostProvider.regionalEndpoint(); + + verify(mockProvider).resolveEndpoint(paramsCaptor.capture()); + assertThat(paramsCaptor.getValue().region()).isEqualTo(region); + } + + private static Stream commonNameTestCases() { + return Stream.of( + // gov regions + new CommonNameTestCase("us-gov-west-1", "sns-us-gov-west-1.amazonaws.com"), + new CommonNameTestCase("us-gov-east-1", "sns-us-gov-west-1.amazonaws.com"), + + // cn regions + new CommonNameTestCase("cn-north-1", "sns-cn-north-1.amazonaws.com.cn"), + new CommonNameTestCase("cn-northwest-1", "sns-cn-northwest-1.amazonaws.com.cn"), + + // opt-in regions + new CommonNameTestCase("me-south-1", "sns-signing.me-south-1.amazonaws.com"), + new CommonNameTestCase("ap-east-1", "sns-signing.ap-east-1.amazonaws.com"), + new CommonNameTestCase("me-south-1", "sns-signing.me-south-1.amazonaws.com"), + new CommonNameTestCase("ap-east-2", "sns-signing.ap-east-2.amazonaws.com"), + new CommonNameTestCase("ap-southeast-5", "sns-signing.ap-southeast-5.amazonaws.com"), + new CommonNameTestCase("ap-southeast-6", "sns-signing.ap-southeast-6.amazonaws.com"), + new CommonNameTestCase("ap-southeast-7", "sns-signing.ap-southeast-7.amazonaws.com"), + new CommonNameTestCase("mx-central-1", "sns-signing.mx-central-1.amazonaws.com"), + + // iso regions + new CommonNameTestCase("us-iso-east-1", "sns-us-iso-east-1.c2s.ic.gov"), + new CommonNameTestCase("us-isob-east-1", "sns-us-isob-east-1.sc2s.sgov.gov"), + new CommonNameTestCase("us-isof-east-1", "sns-signing.us-isof-east-1.csp.hci.ic.gov"), + new CommonNameTestCase("us-isof-south-1", "sns-signing.us-isof-south-1.csp.hci.ic.gov"), + new CommonNameTestCase("eu-isoe-west-1", "sns-signing.eu-isoe-west-1.cloud.adc-e.uk"), + + //eusc + new CommonNameTestCase("eusc-de-east-1", "sns-signing.eusc-de-east-1.amazonaws.eu"), + + // other regions + new CommonNameTestCase("us-east-1", "sns.amazonaws.com"), + new CommonNameTestCase("us-west-1", "sns.amazonaws.com"), + + // unknown regions + new CommonNameTestCase("us-east-9", "sns.amazonaws.com"), + new CommonNameTestCase("foo-bar-1", "sns.amazonaws.com"), + new CommonNameTestCase("cn-northwest-9", "sns.amazonaws.com.cn") + + + ); + } + + private static class CommonNameTestCase { + private String region; + private String expectedCommonName; + + CommonNameTestCase(String region, String expectedCommonName) { + this.region = region; + this.expectedCommonName = expectedCommonName; + } + + @Override + public String toString() { + return region + " - " + expectedCommonName; + } + } +} diff --git a/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/SnsMessageUnmarshallerTest.java b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/SnsMessageUnmarshallerTest.java new file mode 100644 index 000000000000..67722ee592d9 --- /dev/null +++ b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/internal/SnsMessageUnmarshallerTest.java @@ -0,0 +1,221 @@ +/* + * 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 java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.messagemanager.sns.model.SignatureVersion; +import software.amazon.awssdk.messagemanager.sns.model.SnsMessage; +import software.amazon.awssdk.messagemanager.sns.model.SnsMessageType; +import software.amazon.awssdk.messagemanager.sns.model.SnsNotification; +import software.amazon.awssdk.messagemanager.sns.model.SnsSubscriptionConfirmation; +import software.amazon.awssdk.messagemanager.sns.model.SnsUnsubscribeConfirmation; +import software.amazon.awssdk.utils.BinaryUtils; + +public class SnsMessageUnmarshallerTest { + private static final SnsMessageUnmarshaller UNMARSHALLER = new SnsMessageUnmarshaller(); + private static final byte[] TEST_SIGNATURE = BinaryUtils.fromBase64("k764G/ur2Ng="); + private static final SignatureVersion TEST_SIGNATURE_VERSION = SignatureVersion.VERSION_1; + private static final String TEST_TOPIC_ARN = "arn:aws:sns:us-west-2:123456789012:MyTopic"; + private static final String TEST_MESSAGE_ID = "22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324"; + private static final String TEST_MESSAGE = "Hello world!"; + private static final String TEST_SUBJECT = "My First Message"; + private static final URI TEST_SIGNING_CERT_URL = URI.create("https://sns.us-west-2.amazonaws.com/SimpleNotificationService-f3ecfb7224c7233fe7bb5f59f96de52f.pem"); + private static final URI TEST_SUBSCRIBE_URL = URI.create("https://sns.us-west-2.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:us-west-2:123456789012:MyTopic&Token=token"); + private static final URI TEST_UNSUBSCRIBE_URL = URI.create("https://sns.us-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-west-2:123456789012:MyTopic:c9135db0-26c4-47ec-8998-413945fb5a96"); + private static final Instant TEST_TIMESTAMP = Instant.parse("2012-05-02T00:54:06.655Z"); + private static final String TEST_TOKEN = "token"; + + @Test + void unmarshall_notification_maximal() { + String json = "{\n" + + " \"Type\" : \"Notification\",\n" + + " \"MessageId\" : \"22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324\",\n" + + " \"TopicArn\" : \"arn:aws:sns:us-west-2:123456789012:MyTopic\",\n" + + " \"Subject\" : \"My First Message\",\n" + + " \"Message\" : \"Hello world!\",\n" + + " \"Timestamp\" : \"2012-05-02T00:54:06.655Z\",\n" + + " \"SignatureVersion\" : \"1\",\n" + + " \"Signature\" : \"k764G/ur2Ng=\",\n" + + " \"SigningCertURL\" : \"https://sns.us-west-2.amazonaws.com/SimpleNotificationService-f3ecfb7224c7233fe7bb5f59f96de52f.pem\",\n" + + " \"UnsubscribeURL\" : \"https://sns.us-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-west-2:123456789012:MyTopic:c9135db0-26c4-47ec-8998-413945fb5a96\"\n" + + "}"; + + SnsMessage msg = UNMARSHALLER.unmarshall(asStream(json)); + + assertThat(msg).isInstanceOf(SnsNotification.class); + + SnsNotification notification = (SnsNotification) msg; + + assertThat(notification.type()).isEqualTo(SnsMessageType.NOTIFICATION); + assertThat(notification.messageId()).isEqualTo(TEST_MESSAGE_ID); + assertThat(notification.message()).isEqualTo(TEST_MESSAGE); + assertThat(notification.topicArn()).isEqualTo(TEST_TOPIC_ARN); + assertThat(notification.signature().asByteArray()).isEqualTo(TEST_SIGNATURE); + assertThat(notification.signatureVersion()).isEqualTo(TEST_SIGNATURE_VERSION); + assertThat(notification.subject()).isEqualTo(TEST_SUBJECT); + assertThat(notification.timestamp()).isEqualTo(TEST_TIMESTAMP); + assertThat(notification.signingCertUrl()).isEqualTo(TEST_SIGNING_CERT_URL); + assertThat(notification.unsubscribeUrl()).isEqualTo(TEST_UNSUBSCRIBE_URL); + } + + @Test + void unmarshall_subscriptionNotification_maximal() { + String json = "{\n" + + " \"Type\" : \"SubscriptionConfirmation\",\n" + + " \"MessageId\" : \"22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324\",\n" + + " \"Token\" : \"token\",\n" + + " \"TopicArn\" : \"arn:aws:sns:us-west-2:123456789012:MyTopic\",\n" + + " \"Message\" : \"Hello world!\",\n" + + " \"SubscribeURL\" : \"https://sns.us-west-2.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:us-west-2:123456789012:MyTopic&Token=token\",\n" + + " \"Timestamp\" : \"2012-05-02T00:54:06.655Z\",\n" + + " \"SignatureVersion\" : \"1\",\n" + + " \"Signature\" : \"k764G/ur2Ng=\",\n" + + " \"SigningCertURL\" : \"https://sns.us-west-2.amazonaws.com/SimpleNotificationService-f3ecfb7224c7233fe7bb5f59f96de52f.pem\"\n" + + "}"; + + SnsMessage msg = UNMARSHALLER.unmarshall(asStream(json)); + + assertThat(msg).isInstanceOf(SnsSubscriptionConfirmation.class); + + SnsSubscriptionConfirmation subscriptionConfirmation = (SnsSubscriptionConfirmation) msg; + + assertThat(subscriptionConfirmation.type()).isEqualTo(SnsMessageType.SUBSCRIPTION_CONFIRMATION); + assertThat(subscriptionConfirmation.messageId()).isEqualTo(TEST_MESSAGE_ID); + assertThat(subscriptionConfirmation.token()).isEqualTo(TEST_TOKEN); + assertThat(subscriptionConfirmation.topicArn()).isEqualTo(TEST_TOPIC_ARN); + assertThat(subscriptionConfirmation.message()).isEqualTo(TEST_MESSAGE); + assertThat(subscriptionConfirmation.subscribeUrl()).isEqualTo(TEST_SUBSCRIBE_URL); + assertThat(subscriptionConfirmation.timestamp()).isEqualTo(TEST_TIMESTAMP); + assertThat(subscriptionConfirmation.signatureVersion()).isEqualTo(TEST_SIGNATURE_VERSION); + assertThat(subscriptionConfirmation.signature().asByteArray()).isEqualTo(TEST_SIGNATURE); + assertThat(subscriptionConfirmation.signingCertUrl()).isEqualTo(TEST_SIGNING_CERT_URL); + } + + @Test + void unmarshall_unsubscribeConfirmation_maximal() { + String json = "{\n" + + " \"Type\" : \"UnsubscribeConfirmation\",\n" + + " \"MessageId\" : \"22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324\",\n" + + " \"Token\" : \"token\",\n" + + " \"TopicArn\" : \"arn:aws:sns:us-west-2:123456789012:MyTopic\",\n" + + " \"Message\" : \"Hello world!\",\n" + + " \"SubscribeURL\" : \"https://sns.us-west-2.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:us-west-2:123456789012:MyTopic&Token=token\",\n" + + " \"Timestamp\" : \"2012-05-02T00:54:06.655Z\",\n" + + " \"SignatureVersion\" : \"1\",\n" + + " \"Signature\" : \"k764G/ur2Ng=\",\n" + + " \"SigningCertURL\" : \"https://sns.us-west-2.amazonaws.com/SimpleNotificationService-f3ecfb7224c7233fe7bb5f59f96de52f.pem\"\n" + + "}"; + + SnsMessage msg = UNMARSHALLER.unmarshall(asStream(json)); + + assertThat(msg).isInstanceOf(SnsUnsubscribeConfirmation.class); + + SnsUnsubscribeConfirmation unsubscribeConfirmation = (SnsUnsubscribeConfirmation) msg; + + assertThat(unsubscribeConfirmation.type()).isEqualTo(SnsMessageType.UNSUBSCRIBE_CONFIRMATION); + assertThat(unsubscribeConfirmation.messageId()).isEqualTo(TEST_MESSAGE_ID); + assertThat(unsubscribeConfirmation.token()).isEqualTo(TEST_TOKEN); + assertThat(unsubscribeConfirmation.topicArn()).isEqualTo(TEST_TOPIC_ARN); + assertThat(unsubscribeConfirmation.message()).isEqualTo(TEST_MESSAGE); + assertThat(unsubscribeConfirmation.subscribeUrl()).isEqualTo(TEST_SUBSCRIBE_URL); + assertThat(unsubscribeConfirmation.timestamp()).isEqualTo(TEST_TIMESTAMP); + assertThat(unsubscribeConfirmation.signatureVersion()).isEqualTo(TEST_SIGNATURE_VERSION); + assertThat(unsubscribeConfirmation.signature().asByteArray()).isEqualTo(TEST_SIGNATURE); + assertThat(unsubscribeConfirmation.signingCertUrl()).isEqualTo(TEST_SIGNING_CERT_URL); + } + + @Test + void unmarshall_unknownType_throws() { + String json = "{\n" + + " \"Type\" : \"MyCustomType\"\n" + + "}"; + + assertThatThrownBy(() -> UNMARSHALLER.unmarshall(asStream(json))) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("Unsupported sns message type"); + } + + @Test + void unmarshall_jsonNotObject_throws() { + String json = "[]"; + + assertThatThrownBy(() -> UNMARSHALLER.unmarshall(asStream(json))) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("Expected an JSON object"); + } + + @Test + void unmarshall_signatureNotDecodable_throws() { + String json = "{\n" + + " \"Type\" : \"SubscriptionConfirmation\",\n" + + " \"Signature\" : \"this is not base64\"\n" + + "}"; + + assertThatThrownBy(() -> UNMARSHALLER.unmarshall(asStream(json))) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("Unable to decode signature"); + } + + @Test + void unmarshall_timestampNotParsable_throws() { + String json = "{\n" + + " \"Type\" : \"SubscriptionConfirmation\",\n" + + " \"MessageId\" : \"165545c9-2a5c-472c-8df2-7ff2be2b3b1b\",\n" + + " \"Timestamp\" : \"right now\"\n" + + "}"; + + assertThatThrownBy(() -> UNMARSHALLER.unmarshall(asStream(json))) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("Unable to parse timestamp"); + } + + @Test + void unmarshall_uriNotParsable_throws() { + String json = "{\n" + + " \"Type\" : \"SubscriptionConfirmation\",\n" + + " \"SigningCertURL\" : \"https:// not a uri\"\n" + + "}"; + + assertThatThrownBy(() -> UNMARSHALLER.unmarshall(asStream(json))) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("Unable to parse URI"); + } + + @Test + void unmarshall_valueNotString_throws() { + String json = "{\n" + + " \"Type\" : [] \n" + + "}"; + + assertThatThrownBy(() -> UNMARSHALLER.unmarshall(asStream(json))) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("Expected field 'Type' to be a string"); + } + + private static InputStream asStream(String s) { + return new ByteArrayInputStream(s.getBytes(StandardCharsets.UTF_8)); + } + +} 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); + } +} diff --git a/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/model/SnsNotificationTest.java b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/model/SnsNotificationTest.java new file mode 100644 index 000000000000..c32de65a0f3e --- /dev/null +++ b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/model/SnsNotificationTest.java @@ -0,0 +1,28 @@ +/* + * 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.model; + +import nl.jqno.equalsverifier.EqualsVerifier; +import org.junit.jupiter.api.Test; + +public class SnsNotificationTest { + @Test + void equalsHashCode_behave() { + EqualsVerifier.forClass(SnsNotification.class) + .usingGetClass() + .verify(); + } +} diff --git a/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/model/SnsSubscriptionConfirmationTest.java b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/model/SnsSubscriptionConfirmationTest.java new file mode 100644 index 000000000000..369c1e41876d --- /dev/null +++ b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/model/SnsSubscriptionConfirmationTest.java @@ -0,0 +1,28 @@ +/* + * 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.model; + +import nl.jqno.equalsverifier.EqualsVerifier; +import org.junit.jupiter.api.Test; + +public class SnsSubscriptionConfirmationTest { + @Test + void equalsHashCode_behave() { + EqualsVerifier.forClass(SnsSubscriptionConfirmation.class) + .usingGetClass() + .verify(); + } +} diff --git a/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/model/SnsUnsubscribeConfirmationTest.java b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/model/SnsUnsubscribeConfirmationTest.java new file mode 100644 index 000000000000..db116f542954 --- /dev/null +++ b/services-custom/sns-message-manager/src/test/java/software/amazon/awssdk/messagemanager/sns/model/SnsUnsubscribeConfirmationTest.java @@ -0,0 +1,28 @@ +/* + * 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.model; + +import nl.jqno.equalsverifier.EqualsVerifier; +import org.junit.jupiter.api.Test; + +public class SnsUnsubscribeConfirmationTest { + @Test + void equalsHashCode_behave() { + EqualsVerifier.forClass(SnsUnsubscribeConfirmation.class) + .usingGetClass() + .verify(); + } +} diff --git a/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/SimpleNotificationService-7506a1e35b36ef5a444dd1a8e7cc3ed8.pem b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/SimpleNotificationService-7506a1e35b36ef5a444dd1a8e7cc3ed8.pem new file mode 100644 index 000000000000..f5f615db34a5 --- /dev/null +++ b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/SimpleNotificationService-7506a1e35b36ef5a444dd1a8e7cc3ed8.pem @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFxTCCBK2gAwIBAgIQBkyRNHAyygLkObkxH5ju+zANBgkqhkiG9w0BAQsFADA8 +MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRwwGgYDVQQDExNBbWF6b24g +UlNBIDIwNDggTTAxMB4XDTI1MTExMDAwMDAwMFoXDTI2MTAxNDIzNTk1OVowHDEa +MBgGA1UEAxMRc25zLmFtYXpvbmF3cy5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQChbVNQ+DEaT5WN+l+bg0iEeF666Z6LDFc1KJr4lNl0odcXQSsk +KsZYCMVdp4I/Ikk9zWyckRTMkmQGYimpD+bTsMCP5C/sJIU0E9Y2OXjjl5Videkq +qnRL9VdE5N41Rvks7S/5MpYcSmCsmaHfwcv82Gvx2zm9CrA5Xu7K2ZqhzRQPCRSP +mHTHQr4ED63ujE+qTpPgQZ0TvgmcrAc08Z75Z7Zzs17aTNc3YhAtQx9WowN8uiHk +kgc+ONNdtwKvWZNmnbmxMHYtYc291WCP1K1Nh4TV5XNyjIebzQKDrqTCN0ItAr1x +e6Bl3BYS3SG9XHAy0q3ekITzQsKypcuaK6dTAgMBAAGjggLhMIIC3TAfBgNVHSME +GDAWgBSBuA5jiokSGOX6OztQlZ/m5ZAThTAdBgNVHQ4EFgQUBQEUzzzuVJAKzAlR +d0Q//W6HvZUwHAYDVR0RBBUwE4IRc25zLmFtYXpvbmF3cy5jb20wEwYDVR0gBAww +CjAIBgZngQwBAgEwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMB +MDsGA1UdHwQ0MDIwMKAuoCyGKmh0dHA6Ly9jcmwucjJtMDEuYW1hem9udHJ1c3Qu +Y29tL3IybTAxLmNybDB1BggrBgEFBQcBAQRpMGcwLQYIKwYBBQUHMAGGIWh0dHA6 +Ly9vY3NwLnIybTAxLmFtYXpvbnRydXN0LmNvbTA2BggrBgEFBQcwAoYqaHR0cDov +L2NydC5yMm0wMS5hbWF6b250cnVzdC5jb20vcjJtMDEuY2VyMAwGA1UdEwEB/wQC +MAAwggF/BgorBgEEAdZ5AgQCBIIBbwSCAWsBaQB3ANdtfRDRp/V3wsfpX9cAv/mC +yTNaZeHQswFzF8DIxWl3AAABmnAb33MAAAQDAEgwRgIhANXAgMEZWU326cqxqwnf +ntD0XcBjRbmvVMOp3hZ8boPXAiEAkI3NTMXcdLCChHak1ipOae7K7r3spMGUMk6Q +cEUKqBEAdgDCMX5XRRmjRe5/ON6ykEHrx8IhWiK/f9W1rXaa2Q5SzQAAAZpwG9+u +AAAEAwBHMEUCIQD+Qyok8NYaFuQkccW+XIMmxHxj6Yka2wjtg6UN/tbU5wIgcp4z +8MyC/dUfeLRuHAt0owtVgEu7xXqrMBOYlbXrfC4AdgDLOPcViXyEoURfW8Hd+8lu +8ppZzUcKaQWFsMsUwxRY5wAAAZpwG9+yAAAEAwBHMEUCIQCmu1jQUG9A2br7OqwM +FvQXtBX10yIvvDa6a2Ov1Zgg6gIgKlqRvN6WhX0pSHAN2bKmjRwIUI3X9kjS/A/m +DYULaV4wDQYJKoZIhvcNAQELBQADggEBAIbTQWQhqrG6SzwLq79tKCpI0aL0xlD+ +/jrGPxpwTlK7rtAArHiEVh/bsmwLYRlIxPBiU+NE/kqeT0lhJA6/e3X3KRmnpJ6u +Z70PJGYwmZbSRsFom0vGFBMncX0qYx1iD0dooPeAkkSnM5j81rBh+FepV3HfF+tA +9gaglWLsU7Z/A0fdn9D32UPreVHloOut5EyEinvXRUAWZco9CRloyEXJr7Mxq9MZ +vYOK+5wuSPHnPij+fGlQsPxEgoTnCmU03PqNS49kL13jjEnYYdijqL+4rfHtuZbc +4hlVoZfttwg8wD3m5brhQ+GkBBNYN8HFtdVy/BnFi+SPJqv6BgB3Y00= +-----END CERTIFICATE----- diff --git a/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/expired-cert.pem b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/expired-cert.pem new file mode 100644 index 000000000000..ef9b25018ddc --- /dev/null +++ b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/expired-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDIzCCAgugAwIBAgIJAMw+KDBbSfSjMA0GCSqGSIb3DQEBCwUAMCgxJjAkBgNV +BAMMHW15LXRlc3Qtc2VydmljZS5hbWF6b25hd3MuY29tMB4XDTI2MDMxMjIzNDMx +OVoXDTI1MDMxMjIzNDMxOVowKDEmMCQGA1UEAwwdbXktdGVzdC1zZXJ2aWNlLmFt +YXpvbmF3cy5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDIFTPw +f/CQ3ShLHvVf7fbw9x6HyyxgJQmfMMPIeSf15WL6bWLCCO7CdAPmO5YFLIdZVm7/ +OG31kvpx7za2Uo44jPA8zD4ZI4QoHC2tF0JUfyrGej0NucZzS7iTMIfEnch2OA4I +jCm4c7zl+/jSOjkub9XWE/H0s1Kh8edS6rrGRK9xRp7P1HKSKaNzBUAsLF5sKosv +pp4YDKFdZjMj/gL2EsOvyN9buNlklkSux+kvSF4QvT2kj2FX4+IvdGRklXsLkDz8 +SDCDoaBuD6PTDjaD71Fo0D121zBr0/QuAww8sbPz3wabte8ZD1DU3qSs5rNIFfzd +WiJEhATUr2KSedl/AgMBAAGjUDBOMB0GA1UdDgQWBBR/SEgi+B9DpxAoLKN+dWo7 +Pej2xjAfBgNVHSMEGDAWgBR/SEgi+B9DpxAoLKN+dWo7Pej2xjAMBgNVHRMEBTAD +AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAaaAjlmD+C+OCyciaTvezvwY+ny8CBL68y +DdmQeXdOwDLrnfOW8Hkb30dxA1jaKh/yoUZQwQO61g80GnvQq4pohtzdLv3mbSpY +mKx/M+Iojl+uto+DK3mJAMAE1DQCLiW2Hp4AW2uCGetkCdJxIt3N0rlyPirH7Dmr +X903nnrzmkwX6zne2orFJ3cJyXcgU9w6sz+fe3U32yJZfJCwDRobe0aykDnPamIs +yWi6yZUmT2rbPiFH1SBqmSnaTIZApZuEYYjWWiCQ6cYsY9FwvKgafFzUaX8BLsDy +gACoiolwd776w3imAo8ZYP5VbnR4ljCIPSrTKN72nWOjafrikeq7 +-----END CERTIFICATE----- diff --git a/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-notification-no-subject.json b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-notification-no-subject.json new file mode 100644 index 000000000000..45b2e4a01211 --- /dev/null +++ b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-notification-no-subject.json @@ -0,0 +1,11 @@ +{ + "Type" : "Notification", + "MessageId" : "c0e5a280-53c9-5a98-93bb-c0a560b3d192", + "TopicArn" : "arn:aws:sns:us-west-2:131990247566:dongie-standard-topic", + "Message" : "This notification has no subject.", + "Timestamp" : "2026-03-06T19:55:38.903Z", + "SignatureVersion" : "1", + "Signature" : "h5/2kLNyPKvMH+ipOA1kueaA1GLFlcKbO/9o6GbH7BtKk/8lM8krPFDrV+Lwuq8yZDXTxeSXHR8pG1w9MkKQ3VlsHhMigy+5ejIsb+ZfpkDDO6dOACbkTg1twy9tLeKCyPfU01OokKTR6jlKJGCgXXDQm5Q1Fs4NfUz22xGqteycG1GIioqq2Hvd1PLsDkcoyUAhciugGFcZKIGjUbBtdhMTPFX1ed8iH+Y/p6o/eD0G2i614oRSXYRJyV2iNpl54LCCiCrAafAJAM5NXAJJq9CWyTjxHFC18FnPqSukW1/6Qa502oujhPJ/pyl+6mMBoF6LjnpbIyR8/Gmm+RY6fg==", + "SigningCertURL" : "https://sns.us-west-2.amazonaws.com/SimpleNotificationService-7506a1e35b36ef5a444dd1a8e7cc3ed8.pem", + "UnsubscribeURL" : "https://sns.us-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-west-2:131990247566:dongie-standard-topic:b24e11a2-06bd-4e73-aa29-0ed2de3b41b5" +} \ No newline at end of file diff --git a/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-notification-signature-v2.json b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-notification-signature-v2.json new file mode 100644 index 000000000000..2f946f540800 --- /dev/null +++ b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-notification-signature-v2.json @@ -0,0 +1,11 @@ +{ + "Type" : "Notification", + "MessageId" : "9d2c5580-b61d-550c-8583-37a582870907", + "TopicArn" : "arn:aws:sns:us-west-2:131990247566:dongie-standard-topic", + "Message" : "This message uses signature version 2.", + "Timestamp" : "2026-03-06T20:00:26.893Z", + "SignatureVersion" : "2", + "Signature" : "kGNJ7FIM5DJxRv9bdoSJBty2WHpADFWrJ0ZQU/A2sALy6tBE5hqfUvPRlklTJ+PACFMguoFOO7W6ac/Bs4gR+JBRFhx4598X8/HWzrmaLrtB2f7nzViHbpzYN+u+OewXhxjj2lNsu0bLKv6fogkACytDIOhjZylbfJoPphDYAB7T3Ila7u+K3m2wLHH4k1cPYKyBf5FI/kAoQAVsY6qFHeb6CWfHsdM98I2gwyEvwJSzW0iyYBfqckmthQwH3Ex53jrhQlZylXFDYmstSJAdvbLeGqIUxKyk+O25wYgBjbbbeR+2qE2UCGEQrk1Bq018wmTxdHZmDhoBjJXiGmFkfg==", + "SigningCertURL" : "https://sns.us-west-2.amazonaws.com/SimpleNotificationService-7506a1e35b36ef5a444dd1a8e7cc3ed8.pem", + "UnsubscribeURL" : "https://sns.us-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-west-2:131990247566:dongie-standard-topic:b24e11a2-06bd-4e73-aa29-0ed2de3b41b5" +} \ No newline at end of file diff --git a/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-notification-with-subject.json b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-notification-with-subject.json new file mode 100644 index 000000000000..50636ad71652 --- /dev/null +++ b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-notification-with-subject.json @@ -0,0 +1,12 @@ +{ + "Type" : "Notification", + "MessageId" : "26858270-545e-5848-82a2-e05566af3dcf", + "TopicArn" : "arn:aws:sns:us-west-2:131990247566:dongie-standard-topic", + "Subject" : "My message subject", + "Message" : "Hello! This message has a subject.", + "Timestamp" : "2026-03-06T19:53:21.424Z", + "SignatureVersion" : "1", + "Signature" : "jvNSgHVOF9KUOKI3bmQZhURD0kDN6txhfjiyuvnV95Dw6b2SRa7nq3AhM8agP0KUCIrRgKHqUlN6K2XBA35Lzkx1eI5RT0oXIN00pvvzGxSz0ddnuSGmDJM7bd/b1vl2RlLjz+KvkhzEtspxLnxjLsGl2EpOmztt+um7owb4475sOLdGTx1QiFV/8HqB2J0DyjeWdRZvu7Xpsf1Yd2rgmijFoz8x89Vw8pIksjczr4q16pxdh/XKWjbN2uPoC+3EeYdljL8KUvLYH9ueHSs5E9qq2iLdze4+FtANnE73XRvvSGE0KI+FGOvz1igest6qon3g1/CVy4mq3n19TsCSmA==", + "SigningCertURL" : "https://sns.us-west-2.amazonaws.com/SimpleNotificationService-7506a1e35b36ef5a444dd1a8e7cc3ed8.pem", + "UnsubscribeURL" : "https://sns.us-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-west-2:131990247566:dongie-standard-topic:b24e11a2-06bd-4e73-aa29-0ed2de3b41b5" +} \ No newline at end of file diff --git a/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-subscription-confirmation.json b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-subscription-confirmation.json new file mode 100644 index 000000000000..bc1ea5de2894 --- /dev/null +++ b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-subscription-confirmation.json @@ -0,0 +1,12 @@ +{ + "Type" : "SubscriptionConfirmation", + "MessageId" : "f3f2fc87-c426-4c2f-9044-b55890c0939c", + "Token" : "2336412f37fb687f5d51e6e2425929f52e8e353134fe72d55ef8b330217d82da14007cbccefffd35b695feb8f0294d01fd9644f41e1ddfad261519a2c0bac743ff375eeb64530147d2da57f3acc54739da14c8dbd48a9d58ce996eff0d498ea6b4b9f58720d8959b52b99b8de148081720d7da17434e376614a00c992e7823e4", + "TopicArn" : "arn:aws:sns:us-west-2:131990247566:dongie-standard-topic", + "Message" : "You have chosen to subscribe to the topic arn:aws:sns:us-west-2:131990247566:dongie-standard-topic.\nTo confirm the subscription, visit the SubscribeURL included in this message.", + "SubscribeURL" : "https://sns.us-west-2.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:us-west-2:131990247566:dongie-standard-topic&Token=2336412f37fb687f5d51e6e2425929f52e8e353134fe72d55ef8b330217d82da14007cbccefffd35b695feb8f0294d01fd9644f41e1ddfad261519a2c0bac743ff375eeb64530147d2da57f3acc54739da14c8dbd48a9d58ce996eff0d498ea6b4b9f58720d8959b52b99b8de148081720d7da17434e376614a00c992e7823e4", + "Timestamp" : "2026-03-06T19:31:32.895Z", + "SignatureVersion" : "1", + "Signature" : "Ce2Ax8UbEq0RrVH7gsjP9WZ9bzif7dUkAY4OH99Pv7FSrvs8cK+uiH3a6ig54rXWUnZRUejk8K6KeK5Q80O9iJp8KbPRKdQ0PkJ3YASdabhfK0e7evC05YR2pFoS6nNaqNzT7HrR1d1Z9iLoFxluo2Dzb7kWVB7e9jp/y+ZBXRzRIaZgscM84kahBc6QgXOFJA1/PFA4cB5qaa6tBcUFtTE63o9vJ4wMmKk93W9MoszC+dNJeVFr8VHmt3toR+3JauBCZnGdgXgsDhni4PduSfWJJxEpuOK41iYf1hW+xFNFFsv46siTFiHdHiYDfI7/VlaY4spMgkq3EndRr7rHzQ==", + "SigningCertURL" : "https://sns.us-west-2.amazonaws.com/SimpleNotificationService-7506a1e35b36ef5a444dd1a8e7cc3ed8.pem" +} \ No newline at end of file diff --git a/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-unsubscribe-confirmation.json b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-unsubscribe-confirmation.json new file mode 100644 index 000000000000..378cb97be672 --- /dev/null +++ b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/test-unsubscribe-confirmation.json @@ -0,0 +1,12 @@ +{ + "Type" : "UnsubscribeConfirmation", + "MessageId" : "0b71e34e-42c3-486a-9e32-b1928ec994b5", + "Token" : "2336412f37fb687f5d51e6e2425929f52e8e353134fd0284eae5672847af082a982c7dd18215a1485e04e559716f8b42054594757f42dee2d63c46fd3a0fa59b4d688de03db956f8bf9f8cb3167587a28b74c5d325e6a7594d24a43ac534de2dfca614ca89cebd490342c721c30a76ea03def13673437a6a3e450642580119d0", + "TopicArn" : "arn:aws:sns:us-west-2:131990247566:dongie-standard-topic", + "Message" : "You have chosen to deactivate subscription arn:aws:sns:us-west-2:131990247566:dongie-standard-topic:b24e11a2-06bd-4e73-aa29-0ed2de3b41b5.\nTo cancel this operation and restore the subscription, visit the SubscribeURL included in this message.", + "SubscribeURL" : "https://sns.us-west-2.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:us-west-2:131990247566:dongie-standard-topic&Token=2336412f37fb687f5d51e6e2425929f52e8e353134fd0284eae5672847af082a982c7dd18215a1485e04e559716f8b42054594757f42dee2d63c46fd3a0fa59b4d688de03db956f8bf9f8cb3167587a28b74c5d325e6a7594d24a43ac534de2dfca614ca89cebd490342c721c30a76ea03def13673437a6a3e450642580119d0", + "Timestamp" : "2026-03-06T20:02:25.038Z", + "SignatureVersion" : "2", + "Signature" : "RLW/cGWZAdY96iEcvXGmAZQ851DUyWehJIUMHT3Qa0/kX7Wu2QimI44TfE8d9hTqyYFreA6ln4AS67m225DuySpBeiUBtzB/ibiU7jdZmfSiT/I/RTEU3eD0lHHZIYW0M7HAlhHyaSXdcSAbtfnhMBMION93IVl6UOR4kWYdJaJ17/u3by3zlSRs8wCIpj06S6BzuzxrINZPJl+NKZYCVdMlrjuT4etX/UfZkC0Tjchpmu/CQcuimAgswlesm8cVue0WqNPXkOr8h7HmsiGVXPN1jjkYKf2/tVJyBxur/lSksMopER8wtAIsrqY1caAZSI8YJkIndg/b+7eCwkjTBA==", + "SigningCertURL" : "https://sns.us-west-2.amazonaws.com/SimpleNotificationService-7506a1e35b36ef5a444dd1a8e7cc3ed8.pem" +} \ No newline at end of file diff --git a/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/valid-cert.pem b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/valid-cert.pem new file mode 100644 index 000000000000..27815bf67865 --- /dev/null +++ b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/valid-cert.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIICzjCCAbYCCQCKg3/DeNKXtDANBgkqhkiG9w0BAQsFADAoMSYwJAYDVQQDDB1t +eS10ZXN0LXNlcnZpY2UuYW1hem9uYXdzLmNvbTAgFw0yNjAzMTEyMjE4MzJaGA8y +MTI2MDIxNTIyMTgzMlowKDEmMCQGA1UEAwwdbXktdGVzdC1zZXJ2aWNlLmFtYXpv +bmF3cy5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCkTgR9/W8k +xWv9dmxnNHqfJ9Qt4QPsF1qEO/WDwhBinS84wd54QoEraaZOkbcQeB2TSS64rAlr +vy4W3H4XzqZayFraliZyuejmPpx0yI869YwTWclCttDflY/rlM/yXWtc+a7eL1S8 +StIiphT9qZKAwazI4hkB6W8ypvNwkowX7A+kWbuIcSfZCZqXIKZTaROwLl6NtErm +ODj+kZnngmDqIHGwpKyIyNP35wysVkGgVJNO33nXPmhWp8OeUf8V2U/8BjFICb5m +wPu05TLGqlvn1/3gN04J/kKS7HkRzPpZdAfSE1+4ft8e2A0wE3hFU9uKMgFETK5u +j8Wg5LowqUkhAgMBAAEwDQYJKoZIhvcNAQELBQADggEBACCV6A+OGO8wVja56gxJ +rKqC0TXnLte4dEIowzdSLYmcIZ4zLs4Vk7VHoV1JB+iv6VpP1pT4aq0F8nGHZL/1 +SjnsceNfgl6IPqLh6vPARxePtuqVEC6MFjEbvuqWBG0lyUiTt5N20K5sOtEd0b0W +u7lJ83j9JM8RTpJveaT+VdOsiTyYxSGfayTdusk0AgnohkQSBYYUUFy11zr9gaqy +Kxn/N17uVNMqVKJ14y3MTuEAlenZNi9157zm1r4sn6E+vsGB8wQhhJKc/iwTxnBI +eQAumUfssCsgdLSQ7ESP28lXEqB4OTqRgbyXZE7uNkYF+Qj0+KX1Ksx2D1E/dt8j +/7w= +-----END CERTIFICATE----- diff --git a/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/valid-in-future-cert.pem b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/valid-in-future-cert.pem new file mode 100644 index 000000000000..9f4750f3773e --- /dev/null +++ b/services-custom/sns-message-manager/src/test/resources/software/amazon/awssdk/messagemanager/sns/internal/valid-in-future-cert.pem @@ -0,0 +1,29 @@ +-----BEGIN CERTIFICATE----- +MIIE+DCCAuCgAwIBAgIJAOCC5W/Vl4AEMA0GCSqGSIb3DQEBDAUAMCgxJjAkBgNV +BAMTHW15LXRlc3Qtc2VydmljZS5hbWF6b25hd3MuY29tMCIYDzIxMjYwMzEyMjMz +MjQxWhgPMjIyNjAzMTMyMzMyNDFaMCgxJjAkBgNVBAMTHW15LXRlc3Qtc2Vydmlj +ZS5hbWF6b25hd3MuY29tMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA +mmbD6gESHD9gjhhzlovEF2vVKiV12x2zwzj4J08aqi3Vxz8N8DjRTWBDXlecNjq+ +Uvxm2o299dWmIhawHCo0STCZUB4jBA2LreuuTTeU4XK0chU53Fkm2Cp5ib4qIeoE +6XziBv00GVwvwjlglQRuz3rfFYirfJFXpQ9r8Vsbs9GbmTmXiUTtoVkx6CKd0eH4 +LKX4nx6PJfxfTr62r1fAhninyOhsUHx4KS9deKcZEUUqYTKXpTxWJ5STRysYr8y3 +Azlfo82ag8+1nLHq7GIrrsXUegXysQlWYOdBugVGM9YXH6CiJgDE71bc9KnAUNjY +d2+GJhgnZ4LaeM/DYr84FTEmnkSCc01YC3MCOs0Olyb8jQlFNta7rLtGy+qBkFwh +H0cab2UIiY6PWK/EEipDX3kuWWYIm1UFv0Nhm7U3eSwIk8ktFrFLXalUXAEwGrxI +dXaE4BN1Vg2uLB09otDulHJrByii/g79bK6SC6OiaRUn9jG4cz3QidvjN08mPJNu +/SZPw3z+rn4+v/Df8FprsvzPviBo0wdq/IkadFvpXOf2vqNrWv2JMxroCpQQbt8i +tcUThqm9zA5KIMgXGxBfB0k+bmHCcwueBiG5nRD3pIXkz3f/Gb08dJZ/j31bREWA +J5fwES49CXw6Z2+nrlZlWEHe4p6fZ3xvUfaoYYm46wECAwEAAaMhMB8wHQYDVR0O +BBYEFLCmmUBwCbqQ9syEUc3+CIETGJ22MA0GCSqGSIb3DQEBDAUAA4ICAQA9mRPw +HPXymnvbD2SYlZP3EEyBzTY4z5ILLjVG8bmvVb9bEcCSxKYmEV5CjRieQjVDCtlY +GKoGjFxNsTMw56hDcpJfdWrGUm5sToXxR5ZHtp3zB/Q9FxnrWXD64Hq7ND+7e7yL +ycSXsdc6WyQGp4pUKKX0EKDSjHTarDdOlm9E0PEFxJcgYSIUz5yeTPm7zkrO1eS/ +hN2QE7V9/5M583thUhnEWGyq3w8ZKsFwd+fIHLe4MIJMAUpIe8dCAVpluG0JuygC +5Np1PzIKhBDQr4l3Py8uqwsAzP3wDhQ9yFN8jmYQfkUzSuARfukDj2d/rQZkhXhb +2XWFEOaghmWj5cYFKXJVxUfkKbr2pc+/RG99YCV2plJm2R2SVBu3eWv2WjpprmP8 +TeNl7Za4WNCqody0Kfw4Y+IBXH8ANgFw7O9wz9IbnljISFhDsQNCxd3qj0N4HHoV +xd+iOYWzwXMt7GMZnHrCu/WgdiwCJ2i+j3V6eg6xXJP2id84tnxXxxbn9sTFdQ09 +8GzH3+HStWOI650EynNWzksZFD3LGIXUU+6ctRe4jLqUtH3mUYLxrKj4/H1a9R4c +fzlD9vfFu8CjbxarkkClq+pGZ9w/cQ3VxIZ9j7Rotq2OpvXzu8VQV8UE3VUl+jAx +xFhoaR+M1F51+74yEuV/YtRYuQMfsdLRwYNXnw== +-----END CERTIFICATE----- diff --git a/test/architecture-tests/pom.xml b/test/architecture-tests/pom.xml index 1516f32b7f42..ccc82bb7a7f8 100644 --- a/test/architecture-tests/pom.xml +++ b/test/architecture-tests/pom.xml @@ -121,6 +121,11 @@ s3-event-notifications ${awsjavasdk.version} + + software.amazon.awssdk + sns-message-manager + ${awsjavasdk.version} + sso software.amazon.awssdk diff --git a/test/architecture-tests/src/test/java/software/amazon/awssdk/archtests/PackageContainmentTest.java b/test/architecture-tests/src/test/java/software/amazon/awssdk/archtests/PackageContainmentTest.java index 2fc815ed3629..e9692366eb81 100644 --- a/test/architecture-tests/src/test/java/software/amazon/awssdk/archtests/PackageContainmentTest.java +++ b/test/architecture-tests/src/test/java/software/amazon/awssdk/archtests/PackageContainmentTest.java @@ -26,10 +26,13 @@ import java.util.HashSet; import java.util.Set; import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.annotations.SdkProtectedApi; import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.core.internal.http.loader.DefaultSdkHttpClientBuilder; /** * Ensure classes annotated with SdkPublicApi, SdkProtectedApi, and SdkInternalApis are in the right package. @@ -55,6 +58,14 @@ public class PackageContainmentTest { private static final Set ALLOWED_INTERNAL_APIS_OUTSIDE_OF_INTERNAL_PACKAGE = new HashSet<>( Arrays.asList(TRANSFORM_PACKAGE, MODEL_PACKAGE, ENDPOINTS_CONTEXT)); + /** + * Suppressions for APIs that are in '.internal.' packages, but are used outside of the module; i.e. they are APIs that + * began as internal but moved to protected at a later time. + */ + private static final Set ALLOWED_PROTECTED_APIS_IN_INTERNAL_PACKAGE = Stream.of( + Pattern.compile(".*/software/amazon/awssdk/core/internal/http/loader/DefaultSdkHttpClientBuilder\\.class") + ).collect(Collectors.toSet()); + @Test public void internalAPIs_shouldResideInInternalPackage() { JavaClasses importedClasses = new ClassFileImporter() @@ -77,7 +88,9 @@ public void internalAPIs_shouldResideInInternalPackage() { @Test public void publicAndProtectedAPIs_mustNotResideInInternalPackage() { JavaClasses importedClasses = new ClassFileImporter() - .withImportOptions(Arrays.asList(new ImportOption.Predefined.DoNotIncludeTests())) + .withImportOptions(Arrays.asList( + location -> ALLOWED_PROTECTED_APIS_IN_INTERNAL_PACKAGE.stream().noneMatch(location::matches), + new ImportOption.Predefined.DoNotIncludeTests())) .importPackages("software.amazon.awssdk"); ArchRule rule = diff --git a/test/tests-coverage-reporting/pom.xml b/test/tests-coverage-reporting/pom.xml index ae619d1d0c17..ce75c781965a 100644 --- a/test/tests-coverage-reporting/pom.xml +++ b/test/tests-coverage-reporting/pom.xml @@ -306,6 +306,11 @@ s3-event-notifications ${awsjavasdk.version} + + software.amazon.awssdk + sns-message-manager + ${awsjavasdk.version} + sso software.amazon.awssdk