diff --git a/sdk/src/Services/S3/Custom/AmazonS3Client.Extensions.cs b/sdk/src/Services/S3/Custom/AmazonS3Client.Extensions.cs index e3b489e2ff39..a2aad352115a 100644 --- a/sdk/src/Services/S3/Custom/AmazonS3Client.Extensions.cs +++ b/sdk/src/Services/S3/Custom/AmazonS3Client.Extensions.cs @@ -13,15 +13,22 @@ * permissions and limitations under the License. */ using System; +using System.Buffers; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Net; using System.Text; +using System.Text.Json; #if AWS_ASYNC_API using System.Threading.Tasks; #endif +#if !NETFRAMEWORK +using ThirdParty.RuntimeBackports; +#endif + using Amazon.Runtime; using Amazon.Runtime.Internal; using Amazon.Runtime.Internal.Auth; @@ -570,6 +577,245 @@ public async Task GetPreSignedURLAsync(GetPreSignedUrlRequest request) #endif #endregion + #region CreatePresignedPost + + /// + /// Create a presigned POST request that can be used to upload a file directly to S3 from a web browser. + /// + /// The CreatePresignedPostRequest that defines the parameters of the operation. + /// A CreatePresignedPostResponse containing the URL and form fields for the POST request. + /// + /// + public CreatePresignedPostResponse CreatePresignedPost(CreatePresignedPostRequest request) + { + return CreatePresignedPostInternal(request); + } + +#if AWS_ASYNC_API + /// + /// Asynchronously create a presigned POST request that can be used to upload a file directly to S3 from a web browser. + /// + /// The CreatePresignedPostRequest that defines the parameters of the operation. + /// A CreatePresignedPostResponse containing the URL and form fields for the POST request. + /// + /// + public async Task CreatePresignedPostAsync(CreatePresignedPostRequest request) + { + return await CreatePresignedPostInternalAsync(request).ConfigureAwait(false); + } +#endif + + /// + /// Validates the CreatePresignedPostRequest parameters. + /// + /// The request to validate. + /// + /// + private static void ValidateCreatePresignedPostRequest(CreatePresignedPostRequest request) + { + if (request == null) + throw new ArgumentNullException(nameof(request), "The CreatePresignedPostRequest specified is null!"); + + if (string.IsNullOrEmpty(request.BucketName)) + throw new ArgumentException("BucketName is required", nameof(request)); + + if (!request.Expires.HasValue) + throw new ArgumentException("Expires is required", nameof(request)); + + // Check for access point ARNs and reject them - S3 presigned POST doesn't support access points + if (Arn.TryParse(request.BucketName, out var arn)) + { + throw new AmazonS3Exception("S3 presigned POST does not support access points or multi-region access points. " + + "Use the underlying bucket name instead, or consider using presigned PUT URLs as an alternative."); + } + } + + /// + /// Creates and processes the internal request for endpoint resolution. + /// + /// The CreatePresignedPostRequest. + /// The processed IRequest object. + private IRequest CreateAndProcessRequest(CreatePresignedPostRequest request) + { + // Marshall the request to create a proper IRequest object + var irequest = MarshallCreatePresignedPost(request); + + // Use the same endpoint resolution pipeline as GetPreSignedURL + var context = new Amazon.Runtime.Internal.ExecutionContext( + new RequestContext(true, new NullSigner()) + { + Request = irequest, + ClientConfig = this.Config, + OriginalRequest = request, + }, + null + ); + new AmazonS3EndpointResolver().ProcessRequestHandlers(context); + + return irequest; + } + + /// + /// Builds the CreatePresignedPostResponse with URL and form fields. + /// + /// The CreatePresignedPostRequest. + /// The processed IRequest object. + /// The AWS credentials. + /// A CreatePresignedPostResponse containing the URL and form fields for the POST request. + private CreatePresignedPostResponse BuildPresignedPostResponse(CreatePresignedPostRequest request, IRequest irequest, AWSCredentials credentials) + { + // Build the policy document + var policyDocument = BuildPolicyDocument(request); + + // Use S3PostUploadSignedPolicy to sign the policy + var signedPolicy = S3PostUploadSignedPolicy.GetSignedPolicy(policyDocument, credentials, Config.RegionEndpoint); + + // Build the response + var response = new CreatePresignedPostResponse(); + + // Determine the endpoint URL using the processed request context + var parameters = new ServiceOperationEndpointParameters(irequest.OriginalRequest); + var endpoint = Config.DetermineServiceOperationEndpoint(parameters); + response.Url = $"{endpoint.URL}/{request.BucketName}"; + + // Add all the required form fields + response.Fields = new Dictionary(request.Fields); + + // Add the AWS signature fields + response.Fields[S3Constants.PostFormDataObjectKey] = request.Key ?? ""; + response.Fields[S3Constants.PostFormDataPolicy] = signedPolicy.Policy; + response.Fields[S3Constants.PostFormDataXAmzCredential] = signedPolicy.Credential; + response.Fields[S3Constants.PostFormDataXAmzAlgorithm] = signedPolicy.Algorithm; + response.Fields[S3Constants.PostFormDataXAmzDate] = signedPolicy.Date; + response.Fields[S3Constants.PostFormDataXAmzSignature] = signedPolicy.Signature; + + if (!string.IsNullOrEmpty(signedPolicy.SecurityToken)) + { + response.Fields[S3Constants.PostFormDataSecurityToken] = signedPolicy.SecurityToken; + } + + return response; + } + + /// + /// Internal implementation for creating presigned POST requests. + /// + /// The CreatePresignedPostRequest that defines the parameters of the operation. + /// A CreatePresignedPostResponse containing the URL and form fields for the POST request. + /// + /// + internal CreatePresignedPostResponse CreatePresignedPostInternal(CreatePresignedPostRequest request) + { + ValidateCreatePresignedPostRequest(request); + + var credentials = Config.DefaultAWSCredentials ?? DefaultIdentityResolverConfiguration.ResolveDefaultIdentity(); + if (credentials == null) + throw new AmazonS3Exception("Credentials must be specified, cannot call method anonymously"); + + var irequest = CreateAndProcessRequest(request); + return BuildPresignedPostResponse(request, irequest, credentials); + } + + /// + /// Internal implementation for creating presigned POST requests. + /// + /// The CreatePresignedPostRequest that defines the parameters of the operation. + /// A CreatePresignedPostResponse containing the URL and form fields for the POST request. + /// + /// + [SuppressMessage("AWSSDKRules", "CR1004")] + internal async Task CreatePresignedPostInternalAsync(CreatePresignedPostRequest request) + { + ValidateCreatePresignedPostRequest(request); + + var credentials = Config.DefaultAWSCredentials ?? DefaultIdentityResolverConfiguration.ResolveDefaultIdentity(); + if (credentials == null) + throw new AmazonS3Exception("Credentials must be specified, cannot call method anonymously"); + + // Resolve credentials asynchronously + var immutableCredentials = await credentials.GetCredentialsAsync().ConfigureAwait(false); + + var irequest = CreateAndProcessRequest(request); + return BuildPresignedPostResponse(request, irequest, credentials); + } + + /// + /// Marshalls the parameters for a presigned POST request to create a proper IRequest object. + /// + /// The presigned POST request + /// Internal request object + private static IRequest MarshallCreatePresignedPost(CreatePresignedPostRequest createPresignedPostRequest) + { + IRequest request = new DefaultRequest(createPresignedPostRequest, "AmazonS3"); + request.HttpMethod = "POST"; + + // POST requests go to the bucket root, not to a specific key + request.ResourcePath = "/"; + request.UseQueryString = false; // POST uses form data, not query string + + return request; + } + + /// + /// Builds the policy document JSON string from the request using Utf8JsonWriter. + /// This approach follows AWS SDK patterns and is Native AOT compatible. + /// + /// The CreatePresignedPostRequest containing the policy conditions. + /// A JSON string representing the policy document. + private string BuildPolicyDocument(CreatePresignedPostRequest request) + { +#if !NETFRAMEWORK + using var arrayPoolBufferWriter = new ArrayPoolBufferWriter(); + using var writer = new Utf8JsonWriter(arrayPoolBufferWriter); +#else + using var memoryStream = new MemoryStream(); + using var writer = new Utf8JsonWriter(memoryStream); +#endif + + writer.WriteStartObject(); + writer.WriteString("expiration", request.Expires.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")); + writer.WriteStartArray("conditions"); + + // Add bucket condition (required) + writer.WriteStartObject(); + writer.WriteString("bucket", request.BucketName); + writer.WriteEndObject(); + + // Add key condition if specified + if (!string.IsNullOrEmpty(request.Key)) + { + writer.WriteStartObject(); + writer.WriteString("key", request.Key); + writer.WriteEndObject(); + } + + // Add field conditions + foreach (var field in request.Fields) + { + writer.WriteStartObject(); + writer.WriteString(field.Key, field.Value); + writer.WriteEndObject(); + } + + // Add custom conditions using WriteToJsonWriter + foreach (var condition in request.Conditions) + { + condition.WriteToJsonWriter(writer); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + writer.Flush(); + +#if !NETFRAMEWORK + return Encoding.UTF8.GetString(arrayPoolBufferWriter.WrittenMemory.ToArray()); +#else + return Encoding.UTF8.GetString(memoryStream.ToArray()); +#endif + } + + #endregion + #region ICoreAmazonS3 Implementation string ICoreAmazonS3.GeneratePreSignedURL(string bucketName, string objectKey, DateTime expiration, IDictionary additionalProperties) diff --git a/sdk/src/Services/S3/Custom/Util/CreatePresignedPostRequest.cs b/sdk/src/Services/S3/Custom/Util/CreatePresignedPostRequest.cs new file mode 100644 index 000000000000..2e11aa60e195 --- /dev/null +++ b/sdk/src/Services/S3/Custom/Util/CreatePresignedPostRequest.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Amazon.Runtime; +using Amazon.Util; + +namespace Amazon.S3.Util +{ + /// + /// Container for the parameters to create a presigned POST request for S3. + /// + public class CreatePresignedPostRequest : AmazonWebServiceRequest + { + /// + /// Gets or sets the name of the S3 bucket for the presigned POST. + /// + public string BucketName { get; set; } + + /// + /// Gets or sets the key (name) of the object for the presigned POST. + /// + public string Key { get; set; } + + /// + /// Gets or sets the expiration time for the presigned POST. + /// + public DateTime? Expires { get; set; } + + /// + /// Gets or sets additional form fields to include in the presigned POST. + /// + public Dictionary Fields { get; set; } + + /// + /// Gets or sets the policy conditions for the presigned POST. + /// + public List Conditions { get; set; } + + /// + /// Initializes a new instance of the CreatePresignedPostRequest class. + /// + public CreatePresignedPostRequest() + { + Expires = AWSSDKUtils.CorrectedUtcNow.AddHours(1); + Fields = new Dictionary(); + Conditions = new List(); + } + } +} diff --git a/sdk/src/Services/S3/Custom/Util/CreatePresignedPostResponse.cs b/sdk/src/Services/S3/Custom/Util/CreatePresignedPostResponse.cs new file mode 100644 index 000000000000..b3b4774e7a7b --- /dev/null +++ b/sdk/src/Services/S3/Custom/Util/CreatePresignedPostResponse.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Amazon.S3.Util +{ + /// + /// Response from creating a presigned POST request for S3. + /// Contains the URL and form fields needed for a browser-based file upload directly to S3. + /// + public class CreatePresignedPostResponse + { + /// + /// Gets the URL where the POST request should be submitted. + /// + public string Url { get; set; } + + /// + /// Gets the form fields that must be included in the POST request. + /// These fields contain the policy, signature, and other AWS-required parameters. + /// + public Dictionary Fields { get; set; } + + /// + /// Initializes a new instance of the CreatePresignedPostResponse class. + /// + public CreatePresignedPostResponse() + { + Fields = new Dictionary(); + } + } + +} diff --git a/sdk/src/Services/S3/Custom/Util/S3PostCondition.cs b/sdk/src/Services/S3/Custom/Util/S3PostCondition.cs new file mode 100644 index 000000000000..3e64b8e3c696 --- /dev/null +++ b/sdk/src/Services/S3/Custom/Util/S3PostCondition.cs @@ -0,0 +1,439 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; + +namespace Amazon.S3.Util +{ + /// + /// Base abstract class for all S3 POST policy conditions. + /// + /// + /// + /// S3 POST policy conditions are used to restrict what can be uploaded through a presigned POST request. + /// + /// + /// S3 supports three types of conditions in POST policies: + /// + /// + /// Exact Match - Field must exactly match a specified value + /// Starts With - Field value must start with a specified prefix + /// Content Length Range - File size must be within specified byte limits + /// + /// + public abstract class S3PostCondition + { + /// + /// Creates an exact match condition that requires a form field to have exactly the specified value. + /// + /// + /// The name of the form field that must match the expected value. + /// Common field names include "bucket", "acl", "Content-Type", and custom metadata fields + /// prefixed with "x-amz-meta-". + /// + /// + /// The exact value that the form field must have for the upload to be allowed. + /// + /// An for the specified field and value. + /// + /// Thrown when or is null. + /// + /// + /// Thrown when or is empty. + /// + /// + /// + /// // Require uploads to have public-read ACL + /// var aclCondition = S3PostCondition.ExactMatch("acl", "public-read"); + /// + /// // Require specific content type + /// var contentTypeCondition = S3PostCondition.ExactMatch("Content-Type", "image/jpeg"); + /// + /// + public static ExactMatchCondition ExactMatch(string fieldName, string expectedValue) + { + return new ExactMatchCondition(fieldName, expectedValue); + } + + /// + /// Creates a starts-with condition that requires a form field value to begin with the specified prefix. + /// + /// + /// The name of the form field whose value must start with the specified prefix. + /// The most common field is "key" for restricting object key prefixes, but any + /// form field can be used. + /// + /// + /// The prefix that the form field value must start with. Can be an empty string + /// to allow any value (though this makes the condition effectively permissive). + /// + /// A for the specified field and prefix. + /// + /// Thrown when or is null. + /// + /// + /// Thrown when is empty. + /// + /// + /// + /// // Only allow uploads to the "user-uploads/" prefix + /// var keyCondition = S3PostCondition.StartsWith("key", "user-uploads/"); + /// + /// // Restrict uploads to a specific user's folder + /// var userCondition = S3PostCondition.StartsWith("key", $"users/{userId}/"); + /// + /// + public static StartsWithCondition StartsWith(string fieldName, string prefix) + { + return new StartsWithCondition(fieldName, prefix); + } + + /// + /// Creates a content length range condition that restricts file size to the specified byte range. + /// + /// + /// The minimum allowed file size in bytes. Must be non-negative. + /// Use 0 to allow empty files, or 1 to require non-empty files. + /// + /// + /// The maximum allowed file size in bytes. Must be greater than or equal to + /// the minimum length. Consider S3's maximum object size limit of 5TB when setting this value. + /// + /// A for the specified size range. + /// + /// Thrown when is negative, or when + /// is less than . + /// + /// + /// + /// // Allow files between 1KB and 5MB (typical for profile images) + /// var sizeCondition = S3PostCondition.ContentLengthRange(1024, 5 * 1024 * 1024); + /// + /// // Allow documents up to 10MB + /// var docSizeCondition = S3PostCondition.ContentLengthRange(0, 10 * 1024 * 1024); + /// + /// + public static ContentLengthRangeCondition ContentLengthRange(long minimumLength, long maximumLength) + { + return new ContentLengthRangeCondition(minimumLength, maximumLength); + } + + /// + /// Writes the condition to the specified JSON writer in the appropriate format for the S3 POST policy. + /// + /// The JSON writer to write the condition to. + /// + /// This method is called during policy document serialization and writes the condition directly + /// to the JSON writer stream. Each condition type implements this method to produce its specific + /// JSON structure (object for exact match conditions, array for starts-with and content-length-range). + /// + public abstract void WriteToJsonWriter(Utf8JsonWriter writer); + } + + /// + /// Represents an exact match condition in an S3 POST policy. + /// + /// + /// + /// An exact match condition requires that a form field in the POST request has exactly the specified value. + /// This is useful for enforcing specific values for metadata, ACL settings, storage class, etc. + /// + /// + /// The condition is serialized as a JSON object: {"fieldName": "expectedValue"} + /// + /// + /// Common use cases include: + /// + /// + /// Enforcing a specific bucket: new ExactMatchCondition("bucket", "my-uploads") + /// Requiring a specific ACL: new ExactMatchCondition("acl", "public-read") + /// Setting required metadata: new ExactMatchCondition("x-amz-meta-category", "photos") + /// + /// + /// + /// + /// // Require uploads to have public-read ACL + /// var condition = new ExactMatchCondition("acl", "public-read"); + /// + /// // Require specific content type + /// var contentTypeCondition = new ExactMatchCondition("Content-Type", "image/jpeg"); + /// + /// + public class ExactMatchCondition : S3PostCondition + { + /// + /// Gets the name of the form field that must match the expected value. + /// + /// + /// The form field name (e.g., "acl", "Content-Type", "x-amz-meta-category"). + /// Case sensitivity depends on the specific field - most S3 fields are case-insensitive, + /// but user-defined metadata fields are case-sensitive. + /// + public string FieldName { get; } + + /// + /// Gets the exact value that the form field must have. + /// + /// + /// The expected value for the field. The POST request will be rejected if the + /// field value doesn't exactly match this value. + /// + public string ExpectedValue { get; } + + /// + /// Initializes a new instance of the class. + /// + /// + /// The name of the form field that must match the expected value. + /// Common field names include "bucket", "acl", "Content-Type", and custom metadata fields + /// prefixed with "x-amz-meta-". + /// + /// + /// The exact value that the form field must have for the upload to be allowed. + /// + /// + /// Thrown when or is null. + /// + /// + /// Thrown when or is empty. + /// + public ExactMatchCondition(string fieldName, string expectedValue) + { + if (fieldName == null) + throw new ArgumentNullException(nameof(fieldName)); + if (expectedValue == null) + throw new ArgumentNullException(nameof(expectedValue)); + if (string.IsNullOrEmpty(fieldName)) + throw new ArgumentException("Field name cannot be empty", nameof(fieldName)); + if (string.IsNullOrEmpty(expectedValue)) + throw new ArgumentException("Expected value cannot be empty", nameof(expectedValue)); + + FieldName = fieldName; + ExpectedValue = expectedValue; + } + + /// + /// Writes this condition to the specified JSON writer as an object with the field name and expected value. + /// + /// The JSON writer to write the condition to. + /// + /// Writes the condition as a JSON object: {"fieldName": "expectedValue"} + /// The Utf8JsonWriter automatically handles JSON escaping for string values. + /// + public override void WriteToJsonWriter(Utf8JsonWriter writer) + { + writer.WriteStartObject(); + writer.WriteString(FieldName, ExpectedValue); + writer.WriteEndObject(); + } + } + + /// + /// Represents a "starts-with" condition in an S3 POST policy. + /// + /// + /// + /// A starts-with condition requires that a form field value begins with the specified prefix. + /// This is particularly useful for restricting object keys to specific prefixes, allowing + /// organized uploads while maintaining flexibility in naming. + /// + /// + /// The condition is serialized as a JSON array: ["starts-with", "$fieldName", "prefix"] + /// + /// + /// The field name is automatically prefixed with "$" to indicate it's a variable reference + /// in the POST policy. This is required by the S3 POST policy format. + /// + /// + /// Common use cases include: + /// + /// + /// Restricting uploads to a user folder: new StartsWithCondition("key", "users/johndoe/") + /// Organizing by file type: new StartsWithCondition("key", "images/") + /// Enforcing naming conventions: new StartsWithCondition("key", "uploads-2023-") + /// + /// + /// + /// + /// // Only allow uploads to the "user-uploads/" prefix + /// var condition = new StartsWithCondition("key", "user-uploads/"); + /// + /// // Restrict uploads to a specific user's folder + /// var userCondition = new StartsWithCondition("key", $"users/{userId}/"); + /// + /// // Allow uploads with specific metadata prefix + /// var metadataCondition = new StartsWithCondition("x-amz-meta-category", "photo-"); + /// + /// + public class StartsWithCondition : S3PostCondition + { + /// + /// Gets the name of the form field whose value must start with the specified prefix. + /// + /// + /// The form field name (e.g., "key" for object key, "Content-Type" for content type). + /// This will be automatically prefixed with "$" in the policy condition to indicate + /// it's a variable reference. + /// + public string FieldName { get; } + + /// + /// Gets the prefix that the form field value must start with. + /// + /// + /// The required prefix. The POST request will be rejected if the field value + /// doesn't start with this exact prefix. An empty string allows any value. + /// + public string Prefix { get; } + + /// + /// Initializes a new instance of the class. + /// + /// + /// The name of the form field whose value must start with the specified prefix. + /// The most common field is "key" for restricting object key prefixes, but any + /// form field can be used. + /// + /// + /// The prefix that the form field value must start with. Can be an empty string + /// to allow any value (though this makes the condition effectively permissive). + /// + /// + /// Thrown when or is null. + /// + /// + /// Thrown when is empty. + /// + public StartsWithCondition(string fieldName, string prefix) + { + if (fieldName == null) + throw new ArgumentNullException(nameof(fieldName)); + if (prefix == null) + throw new ArgumentNullException(nameof(prefix)); + if (string.IsNullOrEmpty(fieldName)) + throw new ArgumentException("Field name cannot be empty", nameof(fieldName)); + + FieldName = fieldName; + Prefix = prefix; + } + + /// + /// Writes this condition to the specified JSON writer as an array representing the starts-with condition. + /// + /// The JSON writer to write the condition to. + /// + /// Writes the condition as a JSON array: ["starts-with", "$fieldName", "prefix"] + /// The field name is automatically prefixed with "$" as required by S3 POST policy format. + /// The Utf8JsonWriter automatically handles JSON escaping for string values. + /// + public override void WriteToJsonWriter(Utf8JsonWriter writer) + { + writer.WriteStartArray(); + writer.WriteStringValue("starts-with"); + writer.WriteStringValue($"${FieldName}"); + writer.WriteStringValue(Prefix); + writer.WriteEndArray(); + } + } + + /// + /// Represents a content length range condition in an S3 POST policy. + /// + /// + /// + /// A content length range condition restricts the size of files that can be uploaded + /// through the presigned POST request. + /// + /// + /// The condition is serialized as a JSON array: ["content-length-range", minimumLength, maximumLength] + /// + /// + /// Both minimum and maximum values are specified in bytes and are inclusive bounds. + /// The uploaded file size must be greater than or equal to the minimum and less than + /// or equal to the maximum. + /// + /// + /// Common use cases include: + /// + /// + /// Profile photos: new ContentLengthRangeCondition(1024, 5 * 1024 * 1024) (1KB to 5MB) + /// Document uploads: new ContentLengthRangeCondition(0, 10 * 1024 * 1024) (up to 10MB) + /// Preventing empty files: new ContentLengthRangeCondition(1, long.MaxValue) + /// + /// + /// + /// + /// // Allow files between 1KB and 5MB (typical for profile images) + /// var imageSize = new ContentLengthRangeCondition(1024, 5 * 1024 * 1024); + /// + /// // Allow documents up to 10MB + /// var documentSize = new ContentLengthRangeCondition(0, 10 * 1024 * 1024); + /// + /// // Require non-empty files with reasonable maximum + /// var nonEmptySize = new ContentLengthRangeCondition(1, 100 * 1024 * 1024); + /// + /// + public class ContentLengthRangeCondition : S3PostCondition + { + /// + /// Gets the minimum allowed file size in bytes. + /// + /// + /// The minimum file size in bytes (inclusive). Files smaller than this size + /// will be rejected. Must be non-negative and less than or equal to the maximum length. + /// + public long MinimumLength { get; } + + /// + /// Gets the maximum allowed file size in bytes. + /// + /// + /// The maximum file size in bytes (inclusive). Files larger than this size + /// will be rejected. Must be greater than or equal to the minimum length. + /// + public long MaximumLength { get; } + + /// + /// Initializes a new instance of the class. + /// + /// + /// The minimum allowed file size in bytes. Must be non-negative. + /// Use 0 to allow empty files, or 1 to require non-empty files. + /// + /// + /// The maximum allowed file size in bytes. Must be greater than or equal to + /// the minimum length. Consider S3's maximum object size limit of 5TB when setting this value. + /// + /// + /// Thrown when is negative, or when + /// is less than . + /// + public ContentLengthRangeCondition(long minimumLength, long maximumLength) + { + if (minimumLength < 0) + throw new ArgumentException("Minimum length cannot be negative", nameof(minimumLength)); + + if (maximumLength < minimumLength) + throw new ArgumentException("Maximum length must be greater than or equal to minimum length", nameof(maximumLength)); + + MinimumLength = minimumLength; + MaximumLength = maximumLength; + } + + /// + /// Writes this condition to the specified JSON writer as an array representing the content-length-range condition. + /// + /// The JSON writer to write the condition to. + /// + /// Writes the condition as a JSON array: ["content-length-range", minimumLength, maximumLength] + /// The numeric values are written directly without escaping as they are valid JSON numbers. + /// + public override void WriteToJsonWriter(Utf8JsonWriter writer) + { + writer.WriteStartArray(); + writer.WriteStringValue("content-length-range"); + writer.WriteNumberValue(MinimumLength); + writer.WriteNumberValue(MaximumLength); + writer.WriteEndArray(); + } + } +} diff --git a/sdk/test/Services/S3/UnitTests/Custom/CreatePresignedPostRequestTests.cs b/sdk/test/Services/S3/UnitTests/Custom/CreatePresignedPostRequestTests.cs new file mode 100644 index 000000000000..864f006973a8 --- /dev/null +++ b/sdk/test/Services/S3/UnitTests/Custom/CreatePresignedPostRequestTests.cs @@ -0,0 +1,336 @@ +/* + * 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. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Amazon.S3.Util; +using Amazon.Util; + +namespace AWSSDK.UnitTests.S3.Custom +{ + [TestClass] + public class CreatePresignedPostRequestTests + { + [TestMethod] + [TestCategory("S3")] + public void Constructor_InitializesPropertiesWithDefaults() + { + // Act + var request = new CreatePresignedPostRequest(); + + // Assert + Assert.IsNull(request.BucketName); + Assert.IsNull(request.Key); + Assert.IsNotNull(request.Expires); + Assert.IsNotNull(request.Fields); + Assert.IsNotNull(request.Conditions); + + // Default expiration should be approximately 1 hour from now + var expectedExpiration = AWSSDKUtils.CorrectedUtcNow.AddHours(1); + var timeDifference = Math.Abs((request.Expires.Value - expectedExpiration).TotalMinutes); + Assert.IsTrue(timeDifference < 1, "Default expiration should be approximately 1 hour from now"); + } + + [TestMethod] + [TestCategory("S3")] + public void Constructor_InitializesEmptyCollections() + { + // Act + var request = new CreatePresignedPostRequest(); + + // Assert + Assert.AreEqual(0, request.Fields.Count); + Assert.AreEqual(0, request.Conditions.Count); + + // Verify collections are modifiable + request.Fields.Add("test", "value"); + request.Conditions.Add(S3PostCondition.ExactMatch("acl", "public-read")); + + Assert.AreEqual(1, request.Fields.Count); + Assert.AreEqual(1, request.Conditions.Count); + } + + [TestMethod] + [TestCategory("S3")] + public void BucketName_Property_GetSetWorks() + { + // Arrange + var request = new CreatePresignedPostRequest(); + string bucketName = "test-bucket"; + + // Act + request.BucketName = bucketName; + + // Assert + Assert.AreEqual(bucketName, request.BucketName); + } + + [TestMethod] + [TestCategory("S3")] + public void Key_Property_GetSetWorks() + { + // Arrange + var request = new CreatePresignedPostRequest(); + string key = "test-key.jpg"; + + // Act + request.Key = key; + + // Assert + Assert.AreEqual(key, request.Key); + } + + [TestMethod] + [TestCategory("S3")] + public void Expires_Property_GetSetWorks() + { + // Arrange + var request = new CreatePresignedPostRequest(); + var expires = DateTime.UtcNow.AddHours(2); + + // Act + request.Expires = expires; + + // Assert + Assert.AreEqual(expires, request.Expires); + } + + [TestMethod] + [TestCategory("S3")] + public void Fields_Property_CanBeModified() + { + // Arrange + var request = new CreatePresignedPostRequest(); + + // Act + request.Fields["acl"] = "public-read"; + request.Fields["Content-Type"] = "image/jpeg"; + request.Fields["success_action_redirect"] = "https://example.com/success"; + + // Assert + Assert.AreEqual(3, request.Fields.Count); + Assert.AreEqual("public-read", request.Fields["acl"]); + Assert.AreEqual("image/jpeg", request.Fields["Content-Type"]); + Assert.AreEqual("https://example.com/success", request.Fields["success_action_redirect"]); + } + + [TestMethod] + [TestCategory("S3")] + public void Conditions_Property_CanBeModified() + { + // Arrange + var request = new CreatePresignedPostRequest(); + + // Act + request.Conditions.Add(S3PostCondition.ExactMatch("acl", "public-read")); + request.Conditions.Add(S3PostCondition.StartsWith("key", "uploads/")); + request.Conditions.Add(S3PostCondition.ContentLengthRange(1024, 5242880)); + + // Assert + Assert.AreEqual(3, request.Conditions.Count); + + var exactMatch = request.Conditions[0] as ExactMatchCondition; + var startsWith = request.Conditions[1] as StartsWithCondition; + var contentLength = request.Conditions[2] as ContentLengthRangeCondition; + + Assert.IsNotNull(exactMatch); + Assert.AreEqual("acl", exactMatch.FieldName); + Assert.AreEqual("public-read", exactMatch.ExpectedValue); + + Assert.IsNotNull(startsWith); + Assert.AreEqual("key", startsWith.FieldName); + Assert.AreEqual("uploads/", startsWith.Prefix); + + Assert.IsNotNull(contentLength); + Assert.AreEqual(1024, contentLength.MinimumLength); + Assert.AreEqual(5242880, contentLength.MaximumLength); + } + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPostRequest_CompleteExample_AllPropertiesWork() + { + // Arrange & Act + var request = new CreatePresignedPostRequest + { + BucketName = "my-upload-bucket", + Key = "uploads/photo.jpg", + Expires = DateTime.UtcNow.AddMinutes(30) + }; + + request.Fields["acl"] = "public-read"; + request.Fields["Content-Type"] = "image/jpeg"; + request.Fields["success_action_status"] = "201"; + request.Fields["x-amz-meta-category"] = "photos"; + + request.Conditions.Add(S3PostCondition.ExactMatch("bucket", "my-upload-bucket")); + request.Conditions.Add(S3PostCondition.ExactMatch("acl", "public-read")); + request.Conditions.Add(S3PostCondition.StartsWith("key", "uploads/")); + request.Conditions.Add(S3PostCondition.StartsWith("Content-Type", "image/")); + request.Conditions.Add(S3PostCondition.ContentLengthRange(1024, 10485760)); + + // Assert + Assert.AreEqual("my-upload-bucket", request.BucketName); + Assert.AreEqual("uploads/photo.jpg", request.Key); + Assert.IsNotNull(request.Expires); + + Assert.AreEqual(4, request.Fields.Count); + Assert.AreEqual("public-read", request.Fields["acl"]); + Assert.AreEqual("image/jpeg", request.Fields["Content-Type"]); + Assert.AreEqual("201", request.Fields["success_action_status"]); + Assert.AreEqual("photos", request.Fields["x-amz-meta-category"]); + + Assert.AreEqual(5, request.Conditions.Count); + } + + [TestMethod] + [TestCategory("S3")] + public void Fields_Property_HandlesCaseSensitiveKeys() + { + // Arrange + var request = new CreatePresignedPostRequest(); + + // Act + request.Fields["Content-Type"] = "image/jpeg"; + request.Fields["content-type"] = "text/plain"; // Different case + + // Assert + Assert.AreEqual(2, request.Fields.Count); + Assert.AreEqual("image/jpeg", request.Fields["Content-Type"]); + Assert.AreEqual("text/plain", request.Fields["content-type"]); + } + + [TestMethod] + [TestCategory("S3")] + public void Fields_Property_HandlesSpecialCharacters() + { + // Arrange + var request = new CreatePresignedPostRequest(); + + // Act + request.Fields["x-amz-meta-title"] = "文档上传 - Document Upload"; + request.Fields["x-amz-meta-path"] = "folder/subfolder & more/file.txt"; + request.Fields["success_action_redirect"] = "https://example.com/success?id=123&type=upload"; + + // Assert + Assert.AreEqual(3, request.Fields.Count); + Assert.AreEqual("文档上传 - Document Upload", request.Fields["x-amz-meta-title"]); + Assert.AreEqual("folder/subfolder & more/file.txt", request.Fields["x-amz-meta-path"]); + Assert.AreEqual("https://example.com/success?id=123&type=upload", request.Fields["success_action_redirect"]); + } + + [TestMethod] + [TestCategory("S3")] + public void Conditions_Property_HandlesMultipleConditionTypes() + { + // Arrange + var request = new CreatePresignedPostRequest(); + + // Act - Add various condition types + request.Conditions.Add(S3PostCondition.ExactMatch("bucket", "test-bucket")); + request.Conditions.Add(S3PostCondition.ExactMatch("acl", "private")); + request.Conditions.Add(S3PostCondition.StartsWith("key", "private/")); + request.Conditions.Add(S3PostCondition.StartsWith("Content-Type", "")); + request.Conditions.Add(S3PostCondition.ContentLengthRange(0, 1048576)); + request.Conditions.Add(S3PostCondition.ContentLengthRange(1, 1)); + + // Assert + Assert.AreEqual(6, request.Conditions.Count); + + // Verify each condition type is preserved + var exactMatches = request.Conditions.OfType().ToList(); + var startsWiths = request.Conditions.OfType().ToList(); + var contentLengths = request.Conditions.OfType().ToList(); + + Assert.AreEqual(2, exactMatches.Count); + Assert.AreEqual(2, startsWiths.Count); + Assert.AreEqual(2, contentLengths.Count); + } + + [TestMethod] + [TestCategory("S3")] + public void Request_InheritsFromAmazonWebServiceRequest() + { + // Arrange & Act + var request = new CreatePresignedPostRequest(); + + // Assert + Assert.IsInstanceOfType(request, typeof(Amazon.Runtime.AmazonWebServiceRequest)); + } + + [TestMethod] + [TestCategory("S3")] + public void DefaultExpiration_IsReasonableValue() + { + // Arrange + var beforeCreation = AWSSDKUtils.CorrectedUtcNow; + + // Act + var request = new CreatePresignedPostRequest(); + + // Assert + var afterCreation = AWSSDKUtils.CorrectedUtcNow; + + // Default should be 1 hour from creation time + Assert.IsTrue(request.Expires.Value > beforeCreation.AddMinutes(59)); + Assert.IsTrue(request.Expires.Value < afterCreation.AddMinutes(61)); + } + + [TestMethod] + [TestCategory("S3")] + public void Fields_Dictionary_IsNotSharedBetweenInstances() + { + // Arrange + var request1 = new CreatePresignedPostRequest(); + var request2 = new CreatePresignedPostRequest(); + + // Act + request1.Fields["test"] = "value1"; + request2.Fields["test"] = "value2"; + + // Assert + Assert.AreEqual("value1", request1.Fields["test"]); + Assert.AreEqual("value2", request2.Fields["test"]); + Assert.AreEqual(1, request1.Fields.Count); + Assert.AreEqual(1, request2.Fields.Count); + } + + [TestMethod] + [TestCategory("S3")] + public void Conditions_List_IsNotSharedBetweenInstances() + { + // Arrange + var request1 = new CreatePresignedPostRequest(); + var request2 = new CreatePresignedPostRequest(); + + // Act + request1.Conditions.Add(S3PostCondition.ExactMatch("acl", "public-read")); + request2.Conditions.Add(S3PostCondition.ExactMatch("acl", "private")); + + // Assert + Assert.AreEqual(1, request1.Conditions.Count); + Assert.AreEqual(1, request2.Conditions.Count); + + var condition1 = request1.Conditions[0] as ExactMatchCondition; + var condition2 = request2.Conditions[0] as ExactMatchCondition; + + Assert.AreEqual("public-read", condition1.ExpectedValue); + Assert.AreEqual("private", condition2.ExpectedValue); + } + } +} diff --git a/sdk/test/Services/S3/UnitTests/Custom/CreatePresignedPostResponseTests.cs b/sdk/test/Services/S3/UnitTests/Custom/CreatePresignedPostResponseTests.cs new file mode 100644 index 000000000000..a8d93eee066f --- /dev/null +++ b/sdk/test/Services/S3/UnitTests/Custom/CreatePresignedPostResponseTests.cs @@ -0,0 +1,360 @@ +/* + * 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. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Amazon.S3.Util; + +namespace AWSSDK.UnitTests.S3.Custom +{ + [TestClass] + public class CreatePresignedPostResponseTests + { + [TestMethod] + [TestCategory("S3")] + public void Constructor_InitializesPropertiesWithDefaults() + { + // Act + var response = new CreatePresignedPostResponse(); + + // Assert + Assert.IsNull(response.Url); + Assert.IsNotNull(response.Fields); + Assert.AreEqual(0, response.Fields.Count); + } + + [TestMethod] + [TestCategory("S3")] + public void Constructor_InitializesEmptyFieldsDictionary() + { + // Act + var response = new CreatePresignedPostResponse(); + + // Assert + Assert.IsNotNull(response.Fields); + Assert.AreEqual(0, response.Fields.Count); + + // Verify dictionary is modifiable + response.Fields.Add("test", "value"); + Assert.AreEqual(1, response.Fields.Count); + Assert.AreEqual("value", response.Fields["test"]); + } + + [TestMethod] + [TestCategory("S3")] + public void Url_Property_GetSetWorks() + { + // Arrange + var response = new CreatePresignedPostResponse(); + string url = "https://my-bucket.s3.amazonaws.com/"; + + // Act + response.Url = url; + + // Assert + Assert.AreEqual(url, response.Url); + } + + [TestMethod] + [TestCategory("S3")] + public void Fields_Property_CanBeModified() + { + // Arrange + var response = new CreatePresignedPostResponse(); + + // Act + response.Fields["key"] = "uploads/photo.jpg"; + response.Fields["policy"] = "eyJleHBpcmF0aW9uIjoiMjAyNC0wMS0wMVQwMDowMDowMFoiLCJjb25kaXRpb25zIjpbXX0="; + response.Fields["x-amz-algorithm"] = "AWS4-HMAC-SHA256"; + response.Fields["x-amz-credential"] = "AKIAIOSFODNN7EXAMPLE/20240101/us-east-1/s3/aws4_request"; + response.Fields["x-amz-date"] = "20240101T000000Z"; + response.Fields["x-amz-signature"] = "signature-value"; + + // Assert + Assert.AreEqual(6, response.Fields.Count); + Assert.AreEqual("uploads/photo.jpg", response.Fields["key"]); + Assert.AreEqual("eyJleHBpcmF0aW9uIjoiMjAyNC0wMS0wMVQwMDowMDowMFoiLCJjb25kaXRpb25zIjpbXX0=", response.Fields["policy"]); + Assert.AreEqual("AWS4-HMAC-SHA256", response.Fields["x-amz-algorithm"]); + Assert.AreEqual("AKIAIOSFODNN7EXAMPLE/20240101/us-east-1/s3/aws4_request", response.Fields["x-amz-credential"]); + Assert.AreEqual("20240101T000000Z", response.Fields["x-amz-date"]); + Assert.AreEqual("signature-value", response.Fields["x-amz-signature"]); + } + + [TestMethod] + [TestCategory("S3")] + public void Fields_Property_HandlesCaseSensitiveKeys() + { + // Arrange + var response = new CreatePresignedPostResponse(); + + // Act + response.Fields["Content-Type"] = "image/jpeg"; + response.Fields["content-type"] = "text/plain"; // Different case + + // Assert + Assert.AreEqual(2, response.Fields.Count); + Assert.AreEqual("image/jpeg", response.Fields["Content-Type"]); + Assert.AreEqual("text/plain", response.Fields["content-type"]); + } + + [TestMethod] + [TestCategory("S3")] + public void Fields_Property_HandlesSpecialCharacters() + { + // Arrange + var response = new CreatePresignedPostResponse(); + + // Act + response.Fields["key"] = "uploads/文档 & files/test.txt"; + response.Fields["x-amz-meta-title"] = "Document Upload - 文档上传"; + response.Fields["success_action_redirect"] = "https://example.com/success?id=123&status=uploaded"; + + // Assert + Assert.AreEqual(3, response.Fields.Count); + Assert.AreEqual("uploads/文档 & files/test.txt", response.Fields["key"]); + Assert.AreEqual("Document Upload - 文档上传", response.Fields["x-amz-meta-title"]); + Assert.AreEqual("https://example.com/success?id=123&status=uploaded", response.Fields["success_action_redirect"]); + } + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPostResponse_CompleteExample_AllPropertiesWork() + { + // Arrange & Act + var response = new CreatePresignedPostResponse + { + Url = "https://my-upload-bucket.s3.us-east-1.amazonaws.com/" + }; + + response.Fields["key"] = "uploads/photo.jpg"; + response.Fields["acl"] = "public-read"; + response.Fields["Content-Type"] = "image/jpeg"; + response.Fields["policy"] = "eyJleHBpcmF0aW9uIjoiMjAyNC0wMS0wMVQwMDowMDowMFoiLCJjb25kaXRpb25zIjpbXX0="; + response.Fields["x-amz-algorithm"] = "AWS4-HMAC-SHA256"; + response.Fields["x-amz-credential"] = "AKIAIOSFODNN7EXAMPLE/20240101/us-east-1/s3/aws4_request"; + response.Fields["x-amz-date"] = "20240101T000000Z"; + response.Fields["x-amz-signature"] = "signature-value"; + response.Fields["success_action_status"] = "201"; + + // Assert + Assert.AreEqual("https://my-upload-bucket.s3.us-east-1.amazonaws.com/", response.Url); + Assert.AreEqual(9, response.Fields.Count); + + // Verify AWS signature fields + Assert.AreEqual("AWS4-HMAC-SHA256", response.Fields["x-amz-algorithm"]); + Assert.AreEqual("AKIAIOSFODNN7EXAMPLE/20240101/us-east-1/s3/aws4_request", response.Fields["x-amz-credential"]); + Assert.AreEqual("20240101T000000Z", response.Fields["x-amz-date"]); + Assert.AreEqual("signature-value", response.Fields["x-amz-signature"]); + + // Verify other fields + Assert.AreEqual("uploads/photo.jpg", response.Fields["key"]); + Assert.AreEqual("public-read", response.Fields["acl"]); + Assert.AreEqual("image/jpeg", response.Fields["Content-Type"]); + Assert.AreEqual("201", response.Fields["success_action_status"]); + Assert.IsNotNull(response.Fields["policy"]); + } + + [TestMethod] + [TestCategory("S3")] + public void Fields_Dictionary_IsNotSharedBetweenInstances() + { + // Arrange + var response1 = new CreatePresignedPostResponse(); + var response2 = new CreatePresignedPostResponse(); + + // Act + response1.Fields["test"] = "value1"; + response2.Fields["test"] = "value2"; + + // Assert + Assert.AreEqual("value1", response1.Fields["test"]); + Assert.AreEqual("value2", response2.Fields["test"]); + Assert.AreEqual(1, response1.Fields.Count); + Assert.AreEqual(1, response2.Fields.Count); + } + + [TestMethod] + [TestCategory("S3")] + public void Url_Property_HandlesVariousS3Endpoints() + { + // Arrange + var response = new CreatePresignedPostResponse(); + + // Test various S3 endpoint formats + var endpoints = new[] + { + "https://bucket-name.s3.amazonaws.com/", + "https://bucket-name.s3.us-east-1.amazonaws.com/", + "https://bucket-name.s3.eu-west-1.amazonaws.com/", + "https://s3.amazonaws.com/bucket-name", + "https://s3.us-west-2.amazonaws.com/bucket-name", + "https://bucket-name.s3-accelerate.amazonaws.com/" + }; + + foreach (var endpoint in endpoints) + { + // Act + response.Url = endpoint; + + // Assert + Assert.AreEqual(endpoint, response.Url); + } + } + + [TestMethod] + [TestCategory("S3")] + public void Fields_Property_HandlesAwsSignatureFields() + { + // Arrange + var response = new CreatePresignedPostResponse(); + + // Act - Add all AWS signature-related fields + response.Fields["policy"] = "base64-encoded-policy"; + response.Fields["x-amz-algorithm"] = "AWS4-HMAC-SHA256"; + response.Fields["x-amz-credential"] = "AKIAIOSFODNN7EXAMPLE/20240101/us-east-1/s3/aws4_request"; + response.Fields["x-amz-date"] = "20240101T000000Z"; + response.Fields["x-amz-signature"] = "calculated-signature"; + response.Fields["x-amz-security-token"] = "session-token-value"; + + // Assert + Assert.AreEqual(6, response.Fields.Count); + Assert.AreEqual("base64-encoded-policy", response.Fields["policy"]); + Assert.AreEqual("AWS4-HMAC-SHA256", response.Fields["x-amz-algorithm"]); + Assert.AreEqual("AKIAIOSFODNN7EXAMPLE/20240101/us-east-1/s3/aws4_request", response.Fields["x-amz-credential"]); + Assert.AreEqual("20240101T000000Z", response.Fields["x-amz-date"]); + Assert.AreEqual("calculated-signature", response.Fields["x-amz-signature"]); + Assert.AreEqual("session-token-value", response.Fields["x-amz-security-token"]); + } + + [TestMethod] + [TestCategory("S3")] + public void Fields_Property_HandlesS3SpecificFields() + { + // Arrange + var response = new CreatePresignedPostResponse(); + + // Act - Add common S3 POST form fields + response.Fields["key"] = "uploads/${filename}"; + response.Fields["acl"] = "private"; + response.Fields["Content-Type"] = "application/octet-stream"; + response.Fields["Content-Disposition"] = "attachment; filename=\"download.txt\""; + response.Fields["Cache-Control"] = "max-age=3600"; + response.Fields["Expires"] = "Thu, 01 Jan 2025 00:00:00 GMT"; + response.Fields["success_action_status"] = "201"; + response.Fields["success_action_redirect"] = "https://example.com/success"; + response.Fields["x-amz-meta-category"] = "user-uploads"; + response.Fields["x-amz-meta-uploaded-by"] = "user123"; + response.Fields["x-amz-server-side-encryption"] = "AES256"; + response.Fields["x-amz-storage-class"] = "STANDARD_IA"; + + // Assert + Assert.AreEqual(12, response.Fields.Count); + Assert.AreEqual("uploads/${filename}", response.Fields["key"]); + Assert.AreEqual("private", response.Fields["acl"]); + Assert.AreEqual("application/octet-stream", response.Fields["Content-Type"]); + Assert.AreEqual("attachment; filename=\"download.txt\"", response.Fields["Content-Disposition"]); + Assert.AreEqual("max-age=3600", response.Fields["Cache-Control"]); + Assert.AreEqual("Thu, 01 Jan 2025 00:00:00 GMT", response.Fields["Expires"]); + Assert.AreEqual("201", response.Fields["success_action_status"]); + Assert.AreEqual("https://example.com/success", response.Fields["success_action_redirect"]); + Assert.AreEqual("user-uploads", response.Fields["x-amz-meta-category"]); + Assert.AreEqual("user123", response.Fields["x-amz-meta-uploaded-by"]); + Assert.AreEqual("AES256", response.Fields["x-amz-server-side-encryption"]); + Assert.AreEqual("STANDARD_IA", response.Fields["x-amz-storage-class"]); + } + + [TestMethod] + [TestCategory("S3")] + public void Response_FieldsCanBeEnumerated() + { + // Arrange + var response = new CreatePresignedPostResponse(); + response.Fields["key1"] = "value1"; + response.Fields["key2"] = "value2"; + response.Fields["key3"] = "value3"; + + // Act + var keys = response.Fields.Keys.ToList(); + var values = response.Fields.Values.ToList(); + var pairs = response.Fields.ToList(); + + // Assert + Assert.AreEqual(3, keys.Count); + Assert.AreEqual(3, values.Count); + Assert.AreEqual(3, pairs.Count); + + Assert.IsTrue(keys.Contains("key1")); + Assert.IsTrue(keys.Contains("key2")); + Assert.IsTrue(keys.Contains("key3")); + + Assert.IsTrue(values.Contains("value1")); + Assert.IsTrue(values.Contains("value2")); + Assert.IsTrue(values.Contains("value3")); + } + + [TestMethod] + [TestCategory("S3")] + public void Fields_Property_SupportsNullAndEmptyValues() + { + // Arrange + var response = new CreatePresignedPostResponse(); + + // Act + response.Fields["empty"] = ""; + response.Fields["null"] = null; + response.Fields["whitespace"] = " "; + + // Assert + Assert.AreEqual(3, response.Fields.Count); + Assert.AreEqual("", response.Fields["empty"]); + Assert.IsNull(response.Fields["null"]); + Assert.AreEqual(" ", response.Fields["whitespace"]); + } + + [TestMethod] + [TestCategory("S3")] + public void Response_CanBeUsedForHtmlFormGeneration() + { + // Arrange - Create a realistic response that would be used to generate an HTML form + var response = new CreatePresignedPostResponse + { + Url = "https://my-bucket.s3.amazonaws.com/" + }; + + response.Fields["key"] = "uploads/photo-${filename}"; + response.Fields["acl"] = "public-read"; + response.Fields["Content-Type"] = "image/jpeg"; + response.Fields["success_action_status"] = "201"; + response.Fields["policy"] = "eyJleHBpcmF0aW9uIjoiMjAyNC0wMS0wMVQwMDowMDowMFoiLCJjb25kaXRpb25zIjpbXX0="; + response.Fields["x-amz-algorithm"] = "AWS4-HMAC-SHA256"; + response.Fields["x-amz-credential"] = "AKIAIOSFODNN7EXAMPLE/20240101/us-east-1/s3/aws4_request"; + response.Fields["x-amz-date"] = "20240101T000000Z"; + response.Fields["x-amz-signature"] = "calculated-signature"; + + // Act - Verify this response contains everything needed for an HTML form + var requiredFields = new[] { "key", "policy", "x-amz-algorithm", "x-amz-credential", "x-amz-date", "x-amz-signature" }; + var missingFields = requiredFields.Where(field => !response.Fields.ContainsKey(field)).ToList(); + + // Assert + Assert.IsNotNull(response.Url); + Assert.IsTrue(response.Url.StartsWith("https://")); + Assert.AreEqual(0, missingFields.Count, $"Missing required fields: {string.Join(", ", missingFields)}"); + Assert.IsTrue(response.Fields.Count >= 6, "Response should contain at least the required AWS fields"); + } + } +} diff --git a/sdk/test/Services/S3/UnitTests/Custom/CreatePresignedPostTests.cs b/sdk/test/Services/S3/UnitTests/Custom/CreatePresignedPostTests.cs new file mode 100644 index 000000000000..823d612a747f --- /dev/null +++ b/sdk/test/Services/S3/UnitTests/Custom/CreatePresignedPostTests.cs @@ -0,0 +1,671 @@ +/* + * 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. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +using Amazon; +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Util; +using Amazon.Util; + +namespace AWSSDK.UnitTests.S3.Custom +{ + [TestClass] + public class CreatePresignedPostTests + { + private Mock _mockCredentials; + private AmazonS3Config _config; + private AmazonS3Client _s3Client; + + [TestInitialize] + public void Setup() + { + _mockCredentials = new Mock(); + _config = new AmazonS3Config + { + RegionEndpoint = RegionEndpoint.USEast1 + }; + + // Setup mock credentials to return test values + var immutableCreds = new ImmutableCredentials("AKIAIOSFODNN7EXAMPLE", "test-secret-key", null); + _mockCredentials.Setup(c => c.GetCredentials()).Returns(immutableCreds); + _mockCredentials.Setup(c => c.GetCredentialsAsync()).ReturnsAsync(immutableCreds); + + // Create S3 client with mock credentials + _s3Client = new AmazonS3Client(_mockCredentials.Object, _config); + } + + [TestCleanup] + public void Cleanup() + { + _s3Client?.Dispose(); + } + + #region Validation Tests + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_NullRequest_ThrowsArgumentNullException() + { + // Act & Assert + Assert.ThrowsException(() => + _s3Client.CreatePresignedPost(null)); + } + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_MissingBucketName_ThrowsArgumentException() + { + // Arrange + var request = new CreatePresignedPostRequest + { + BucketName = null, + Expires = DateTime.UtcNow.AddHours(1) + }; + + // Act & Assert + var exception = Assert.ThrowsException(() => + _s3Client.CreatePresignedPost(request)); + Assert.IsTrue(exception.Message.Contains("BucketName")); + } + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_EmptyBucketName_ThrowsArgumentException() + { + // Arrange + var request = new CreatePresignedPostRequest + { + BucketName = "", + Expires = DateTime.UtcNow.AddHours(1) + }; + + // Act & Assert + var exception = Assert.ThrowsException(() => + _s3Client.CreatePresignedPost(request)); + Assert.IsTrue(exception.Message.Contains("BucketName")); + } + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_MissingExpires_ThrowsArgumentException() + { + // Arrange + var request = new CreatePresignedPostRequest + { + BucketName = "test-bucket", + Expires = null + }; + + // Act & Assert + var exception = Assert.ThrowsException(() => + _s3Client.CreatePresignedPost(request)); + Assert.IsTrue(exception.Message.Contains("Expires")); + } + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_AccessPointArn_ThrowsAmazonS3Exception() + { + // Arrange + var request = new CreatePresignedPostRequest + { + BucketName = "arn:aws:s3:us-east-1:123456789012:accesspoint/my-access-point", + Expires = DateTime.UtcNow.AddHours(1) + }; + + // Act & Assert + var exception = Assert.ThrowsException(() => + _s3Client.CreatePresignedPost(request)); + Assert.IsTrue(exception.Message.Contains("presigned POST does not support access points")); + } + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_MultiRegionAccessPointArn_ThrowsAmazonS3Exception() + { + // Arrange + var request = new CreatePresignedPostRequest + { + BucketName = "arn:aws:s3::123456789012:accesspoint:mfzwi23gnjvgw.mrap", + Expires = DateTime.UtcNow.AddHours(1) + }; + + // Act & Assert + var exception = Assert.ThrowsException(() => + _s3Client.CreatePresignedPost(request)); + Assert.IsTrue(exception.Message.Contains("presigned POST does not support access points")); + } + + #endregion + + #region Basic Functionality Tests + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_ValidRequest_ReturnsResponse() + { + // Arrange + var request = new CreatePresignedPostRequest + { + BucketName = "test-bucket", + Key = "test-key.jpg", + Expires = DateTime.UtcNow.AddHours(1) + }; + + // Act + var response = _s3Client.CreatePresignedPost(request); + + // Assert + Assert.IsNotNull(response); + Assert.IsNotNull(response.Url); + Assert.IsNotNull(response.Fields); + Assert.IsTrue(response.Fields.Count > 0); + } + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_WithCustomFields_IncludesCustomFields() + { + // Arrange + var request = new CreatePresignedPostRequest + { + BucketName = "test-bucket", + Key = "test-key.jpg", + Expires = DateTime.UtcNow.AddHours(1) + }; + request.Fields["acl"] = "public-read"; + request.Fields["Content-Type"] = "image/jpeg"; + request.Fields["success_action_status"] = "201"; + + // Act + var response = _s3Client.CreatePresignedPost(request); + + // Assert + Assert.AreEqual("public-read", response.Fields["acl"]); + Assert.AreEqual("image/jpeg", response.Fields["Content-Type"]); + Assert.AreEqual("201", response.Fields["success_action_status"]); + } + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_WithConditions_GeneratesValidPolicy() + { + // Arrange + var request = new CreatePresignedPostRequest + { + BucketName = "test-bucket", + Key = "uploads/photo.jpg", + Expires = DateTime.UtcNow.AddHours(1) + }; + request.Conditions.Add(S3PostCondition.ExactMatch("acl", "public-read")); + request.Conditions.Add(S3PostCondition.StartsWith("key", "uploads/")); + request.Conditions.Add(S3PostCondition.ContentLengthRange(1024, 5242880)); + + // Act + var response = _s3Client.CreatePresignedPost(request); + + // Assert + Assert.IsNotNull(response.Fields["Policy"]); + + // Decode and verify the policy + var policyBytes = Convert.FromBase64String(response.Fields["Policy"]); + var policyJson = System.Text.Encoding.UTF8.GetString(policyBytes); + var policyDoc = JsonDocument.Parse(policyJson); + + var conditions = policyDoc.RootElement.GetProperty("conditions"); + Assert.IsTrue(conditions.GetArrayLength() > 3); // Should have bucket + custom conditions + } + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_UrlFormat_IsCorrect() + { + // Arrange + var request = new CreatePresignedPostRequest + { + BucketName = "my-test-bucket", + Expires = DateTime.UtcNow.AddHours(1) + }; + + // Act + var response = _s3Client.CreatePresignedPost(request); + + // Assert + Assert.IsTrue(response.Url.Contains("my-test-bucket")); + Assert.IsTrue(response.Url.StartsWith("https://") || response.Url.StartsWith("http://")); + } + + #endregion + + #region Policy Document Tests + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_PolicyDocument_ContainsBucketCondition() + { + // Arrange + var request = new CreatePresignedPostRequest + { + BucketName = "policy-test-bucket", + Expires = DateTime.UtcNow.AddHours(1) + }; + + // Act + var response = _s3Client.CreatePresignedPost(request); + + // Assert + var policyBytes = Convert.FromBase64String(response.Fields["Policy"]); + var policyJson = System.Text.Encoding.UTF8.GetString(policyBytes); + + Assert.IsTrue(policyJson.Contains("\"bucket\"")); + Assert.IsTrue(policyJson.Contains("\"policy-test-bucket\"")); + } + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_PolicyDocument_ContainsKeyCondition() + { + // Arrange + var request = new CreatePresignedPostRequest + { + BucketName = "test-bucket", + Key = "uploads/specific-key.jpg", + Expires = DateTime.UtcNow.AddHours(1) + }; + + // Act + var response = _s3Client.CreatePresignedPost(request); + + // Assert + var policyBytes = Convert.FromBase64String(response.Fields["Policy"]); + var policyJson = System.Text.Encoding.UTF8.GetString(policyBytes); + + Assert.IsTrue(policyJson.Contains("\"key\"")); + Assert.IsTrue(policyJson.Contains("\"uploads/specific-key.jpg\"")); + } + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_PolicyDocument_ContainsExpiration() + { + // Arrange + var expires = DateTime.UtcNow.AddHours(2); + var request = new CreatePresignedPostRequest + { + BucketName = "test-bucket", + Expires = expires + }; + + // Act + var response = _s3Client.CreatePresignedPost(request); + + // Assert + var policyBytes = Convert.FromBase64String(response.Fields["Policy"]); + var policyJson = System.Text.Encoding.UTF8.GetString(policyBytes); + var policyDoc = JsonDocument.Parse(policyJson); + + var expiration = policyDoc.RootElement.GetProperty("expiration").GetString(); + Assert.IsNotNull(expiration); + Assert.IsTrue(expiration.EndsWith("Z")); // Should be ISO 8601 UTC format + } + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_PolicyDocument_IncludesCustomConditions() + { + // Arrange + var request = new CreatePresignedPostRequest + { + BucketName = "test-bucket", + Expires = DateTime.UtcNow.AddHours(1) + }; + request.Conditions.Add(S3PostCondition.ExactMatch("acl", "private")); + request.Conditions.Add(S3PostCondition.StartsWith("Content-Type", "image/")); + request.Conditions.Add(S3PostCondition.ContentLengthRange(100, 1000000)); + + // Act + var response = _s3Client.CreatePresignedPost(request); + + // Assert + var policyBytes = Convert.FromBase64String(response.Fields["Policy"]); + var policyJson = System.Text.Encoding.UTF8.GetString(policyBytes); + + // Verify exact match condition + Assert.IsTrue(policyJson.Contains("\"acl\"")); + Assert.IsTrue(policyJson.Contains("\"private\"")); + + // Verify starts-with condition + Assert.IsTrue(policyJson.Contains("\"starts-with\"")); + Assert.IsTrue(policyJson.Contains("\"$Content-Type\"")); + Assert.IsTrue(policyJson.Contains("\"image/\"")); + + // Verify content-length-range condition + Assert.IsTrue(policyJson.Contains("\"content-length-range\"")); + Assert.IsTrue(policyJson.Contains("100")); + Assert.IsTrue(policyJson.Contains("1000000")); + } + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_PolicyDocument_IncludesFormFields() + { + // Arrange + var request = new CreatePresignedPostRequest + { + BucketName = "test-bucket", + Expires = DateTime.UtcNow.AddHours(1) + }; + request.Fields["success_action_status"] = "201"; + request.Fields["x-amz-meta-category"] = "photos"; + + // Act + var response = _s3Client.CreatePresignedPost(request); + + // Assert + var policyBytes = Convert.FromBase64String(response.Fields["Policy"]); + var policyJson = System.Text.Encoding.UTF8.GetString(policyBytes); + + Assert.IsTrue(policyJson.Contains("\"success_action_status\"")); + Assert.IsTrue(policyJson.Contains("\"201\"")); + Assert.IsTrue(policyJson.Contains("\"x-amz-meta-category\"")); + Assert.IsTrue(policyJson.Contains("\"photos\"")); + } + + #endregion + + #region AWS Signature Fields Tests + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_SignatureFields_AreCorrectFormat() + { + // Arrange + var request = new CreatePresignedPostRequest + { + BucketName = "test-bucket", + Expires = DateTime.UtcNow.AddHours(1) + }; + + // Act + var response = _s3Client.CreatePresignedPost(request); + + // Assert + Assert.AreEqual("AWS4-HMAC-SHA256", response.Fields["x-amz-algorithm"]); + + var credential = response.Fields["x-amz-credential"]; + Assert.IsTrue(credential.StartsWith("AKIAIOSFODNN7EXAMPLE/")); + Assert.IsTrue(credential.Contains("/us-east-1/s3/aws4_request")); + + var date = response.Fields["x-amz-date"]; + Assert.IsTrue(date.EndsWith("Z")); + Assert.AreEqual(16, date.Length); // Format: YYYYMMDDTHHMMSSZ + + Assert.IsNotNull(response.Fields["x-amz-signature"]); + Assert.IsTrue(response.Fields["x-amz-signature"].Length > 10); + } + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_WithSecurityToken_IncludesTokenField() + { + // Arrange + var immutableCredsWithToken = new ImmutableCredentials("AKIAIOSFODNN7EXAMPLE", "test-secret-key", "security-token-123"); + _mockCredentials.Setup(c => c.GetCredentials()).Returns(immutableCredsWithToken); + _mockCredentials.Setup(c => c.GetCredentialsAsync()).ReturnsAsync(immutableCredsWithToken); + + var request = new CreatePresignedPostRequest + { + BucketName = "test-bucket", + Expires = DateTime.UtcNow.AddHours(1) + }; + + // Act + var response = _s3Client.CreatePresignedPost(request); + + // Assert + Assert.IsTrue(response.Fields.ContainsKey("x-amz-security-token")); + Assert.AreEqual("security-token-123", response.Fields["x-amz-security-token"]); + } + + #endregion + + #region Async Tests + + [TestMethod] + [TestCategory("S3")] + public async Task CreatePresignedPostAsync_ValidRequest_ReturnsResponse() + { + // Arrange + var request = new CreatePresignedPostRequest + { + BucketName = "test-bucket", + Key = "async-test-key.jpg", + Expires = DateTime.UtcNow.AddHours(1) + }; + + // Act + var response = await _s3Client.CreatePresignedPostAsync(request); + + // Assert + Assert.IsNotNull(response); + Assert.IsNotNull(response.Url); + Assert.IsNotNull(response.Fields); + Assert.IsTrue(response.Fields.Count > 0); + Assert.AreEqual("async-test-key.jpg", response.Fields["key"]); + } + + [TestMethod] + [TestCategory("S3")] + public async Task CreatePresignedPostAsync_NullRequest_ThrowsArgumentNullException() + { + // Act & Assert + await Assert.ThrowsExceptionAsync(() => + _s3Client.CreatePresignedPostAsync(null)); + } + + [TestMethod] + [TestCategory("S3")] + public async Task CreatePresignedPostAsync_MissingBucketName_ThrowsArgumentException() + { + // Arrange + var request = new CreatePresignedPostRequest + { + BucketName = null, + Expires = DateTime.UtcNow.AddHours(1) + }; + + // Act & Assert + var exception = await Assert.ThrowsExceptionAsync(() => + _s3Client.CreatePresignedPostAsync(request)); + Assert.IsTrue(exception.Message.Contains("BucketName")); + } + + #endregion + + #region Edge Cases and Error Handling + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_EmptyKey_HandledCorrectly() + { + // Arrange + var request = new CreatePresignedPostRequest + { + BucketName = "test-bucket", + Key = "", // Empty key should be handled + Expires = DateTime.UtcNow.AddHours(1) + }; + + // Act + var response = _s3Client.CreatePresignedPost(request); + + // Assert + Assert.IsNotNull(response); + Assert.AreEqual("", response.Fields["key"]); + } + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_NullKey_HandledCorrectly() + { + // Arrange + var request = new CreatePresignedPostRequest + { + BucketName = "test-bucket", + Key = null, // Null key should be handled + Expires = DateTime.UtcNow.AddHours(1) + }; + + // Act + var response = _s3Client.CreatePresignedPost(request); + + // Assert + Assert.IsNotNull(response); + Assert.AreEqual("", response.Fields["key"]); // Should default to empty string + } + + #endregion + + #region Special Characters and Unicode Tests + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_SpecialCharactersInBucketAndKey_HandledCorrectly() + { + // Arrange + var request = new CreatePresignedPostRequest + { + BucketName = "test-bucket-with-dashes", + Key = "uploads/files with spaces & symbols/test.txt", + Expires = DateTime.UtcNow.AddHours(1) + }; + + // Act + var response = _s3Client.CreatePresignedPost(request); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Url.Contains("test-bucket-with-dashes")); + Assert.AreEqual("uploads/files with spaces & symbols/test.txt", response.Fields["key"]); + } + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_UnicodeCharacters_HandledCorrectly() + { + // Arrange + var request = new CreatePresignedPostRequest + { + BucketName = "test-bucket", + Key = "uploads/文档/测试文件.txt", + Expires = DateTime.UtcNow.AddHours(1) + }; + request.Fields["x-amz-meta-title"] = "测试文档 - Test Document"; + + // Act + var response = _s3Client.CreatePresignedPost(request); + + // Assert + Assert.IsNotNull(response); + Assert.AreEqual("uploads/文档/测试文件.txt", response.Fields["key"]); + Assert.AreEqual("测试文档 - Test Document", response.Fields["x-amz-meta-title"]); + + // Verify policy document handles Unicode correctly + var policyBytes = Convert.FromBase64String(response.Fields["Policy"]); + var policyJson = System.Text.Encoding.UTF8.GetString(policyBytes); + var policyDoc = JsonDocument.Parse(policyJson); // Should not throw for valid JSON + Assert.IsNotNull(policyDoc); + } + + #endregion + + #region Comprehensive Integration Test + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_ComplexScenario_GeneratesCompleteResponse() + { + // Arrange - Create a comprehensive scenario + var request = new CreatePresignedPostRequest + { + BucketName = "my-photo-uploads", + Key = "users/johndoe/photos/vacation-2024/beach.jpg", + Expires = DateTime.UtcNow.AddMinutes(30) + }; + + // Add form fields + request.Fields["acl"] = "public-read"; + request.Fields["Content-Type"] = "image/jpeg"; + request.Fields["success_action_status"] = "201"; + request.Fields["success_action_redirect"] = "https://myapp.com/upload-success"; + request.Fields["x-amz-meta-category"] = "vacation-photos"; + request.Fields["x-amz-meta-uploader"] = "johndoe"; + + // Add policy conditions + request.Conditions.Add(S3PostCondition.ExactMatch("bucket", "my-photo-uploads")); + request.Conditions.Add(S3PostCondition.ExactMatch("acl", "public-read")); + request.Conditions.Add(S3PostCondition.StartsWith("key", "users/johndoe/")); + request.Conditions.Add(S3PostCondition.StartsWith("Content-Type", "image/")); + request.Conditions.Add(S3PostCondition.ContentLengthRange(1024, 10 * 1024 * 1024)); // 1KB to 10MB + + // Act + var response = _s3Client.CreatePresignedPost(request); + + // Assert - Verify URL + Assert.IsNotNull(response.Url); + Assert.IsTrue(response.Url.Contains("my-photo-uploads")); + + // Assert - Verify required AWS fields + Assert.AreEqual("users/johndoe/photos/vacation-2024/beach.jpg", response.Fields["key"]); + Assert.AreEqual("AWS4-HMAC-SHA256", response.Fields["x-amz-algorithm"]); + Assert.IsTrue(response.Fields["x-amz-credential"].StartsWith("AKIAIOSFODNN7EXAMPLE/")); + Assert.IsNotNull(response.Fields["x-amz-date"]); + Assert.IsNotNull(response.Fields["x-amz-signature"]); + Assert.IsNotNull(response.Fields["Policy"]); + + // Assert - Verify custom fields + Assert.AreEqual("public-read", response.Fields["acl"]); + Assert.AreEqual("image/jpeg", response.Fields["Content-Type"]); + Assert.AreEqual("201", response.Fields["success_action_status"]); + Assert.AreEqual("https://myapp.com/upload-success", response.Fields["success_action_redirect"]); + Assert.AreEqual("vacation-photos", response.Fields["x-amz-meta-category"]); + Assert.AreEqual("johndoe", response.Fields["x-amz-meta-uploader"]); + + // Assert - Verify policy document contains all conditions + var policyBytes = Convert.FromBase64String(response.Fields["Policy"]); + var policyJson = System.Text.Encoding.UTF8.GetString(policyBytes); + + Assert.IsTrue(policyJson.Contains("\"my-photo-uploads\"")); + Assert.IsTrue(policyJson.Contains("\"users/johndoe/\"")); + Assert.IsTrue(policyJson.Contains("\"image/\"")); + Assert.IsTrue(policyJson.Contains("\"content-length-range\"")); + Assert.IsTrue(policyJson.Contains(1024.ToString())); + Assert.IsTrue(policyJson.Contains((10 * 1024 * 1024).ToString())); + + // Assert - Total field count should be reasonable + Assert.IsTrue(response.Fields.Count >= 11); // At least 6 AWS fields + 6 custom fields + } + + #endregion + } +} diff --git a/sdk/test/Services/S3/UnitTests/Custom/S3PostConditionTests.cs b/sdk/test/Services/S3/UnitTests/Custom/S3PostConditionTests.cs new file mode 100644 index 000000000000..7d851f92430d --- /dev/null +++ b/sdk/test/Services/S3/UnitTests/Custom/S3PostConditionTests.cs @@ -0,0 +1,555 @@ +/* + * 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. + */ + +using System; +using System.IO; +using System.Text; +using System.Text.Json; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Amazon.S3.Util; + +namespace AWSSDK.UnitTests.S3.Custom +{ + [TestClass] + public class S3PostConditionTests + { + #region ExactMatchCondition Tests + + [TestMethod] + [TestCategory("S3")] + public void ExactMatchCondition_Constructor_ValidParameters_SetsProperties() + { + // Arrange + string fieldName = "acl"; + string expectedValue = "public-read"; + + // Act + var condition = new ExactMatchCondition(fieldName, expectedValue); + + // Assert + Assert.AreEqual(fieldName, condition.FieldName); + Assert.AreEqual(expectedValue, condition.ExpectedValue); + } + + [TestMethod] + [TestCategory("S3")] + public void ExactMatchCondition_Constructor_NullFieldName_ThrowsArgumentNullException() + { + // Arrange + string fieldName = null; + string expectedValue = "public-read"; + + // Act & Assert + Assert.ThrowsException(() => + new ExactMatchCondition(fieldName, expectedValue)); + } + + [TestMethod] + [TestCategory("S3")] + public void ExactMatchCondition_Constructor_NullExpectedValue_ThrowsArgumentNullException() + { + // Arrange + string fieldName = "acl"; + string expectedValue = null; + + // Act & Assert + Assert.ThrowsException(() => + new ExactMatchCondition(fieldName, expectedValue)); + } + + [TestMethod] + [TestCategory("S3")] + public void ExactMatchCondition_Constructor_EmptyFieldName_ThrowsArgumentException() + { + // Arrange + string fieldName = ""; + string expectedValue = "public-read"; + + // Act & Assert + Assert.ThrowsException(() => + new ExactMatchCondition(fieldName, expectedValue)); + } + + [TestMethod] + [TestCategory("S3")] + public void ExactMatchCondition_Constructor_EmptyExpectedValue_ThrowsArgumentException() + { + // Arrange + string fieldName = "acl"; + string expectedValue = ""; + + // Act & Assert + Assert.ThrowsException(() => + new ExactMatchCondition(fieldName, expectedValue)); + } + + [TestMethod] + [TestCategory("S3")] + public void ExactMatchCondition_WriteToJsonWriter_ProducesCorrectJson() + { + // Arrange + var condition = new ExactMatchCondition("acl", "public-read"); + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + + // Act + condition.WriteToJsonWriter(writer); + writer.Flush(); + + // Assert + var json = Encoding.UTF8.GetString(stream.ToArray()); + var expectedJson = "{\"acl\":\"public-read\"}"; + Assert.AreEqual(expectedJson, json); + } + + [TestMethod] + [TestCategory("S3")] + public void ExactMatchCondition_WriteToJsonWriter_HandlesSpecialCharacters() + { + // Arrange + var condition = new ExactMatchCondition("x-amz-meta-category", "files/docs & notes"); + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + + // Act + condition.WriteToJsonWriter(writer); + writer.Flush(); + + // Assert + var json = Encoding.UTF8.GetString(stream.ToArray()); + Assert.IsTrue(json.Contains("x-amz-meta-category")); + Assert.IsTrue(json.Contains("files/docs \\u0026 notes")); // JSON escaped + } + + #endregion + + #region StartsWithCondition Tests + + [TestMethod] + [TestCategory("S3")] + public void StartsWithCondition_Constructor_ValidParameters_SetsProperties() + { + // Arrange + string fieldName = "key"; + string prefix = "user-uploads/"; + + // Act + var condition = new StartsWithCondition(fieldName, prefix); + + // Assert + Assert.AreEqual(fieldName, condition.FieldName); + Assert.AreEqual(prefix, condition.Prefix); + } + + [TestMethod] + [TestCategory("S3")] + public void StartsWithCondition_Constructor_NullFieldName_ThrowsArgumentNullException() + { + // Arrange + string fieldName = null; + string prefix = "user-uploads/"; + + // Act & Assert + Assert.ThrowsException(() => + new StartsWithCondition(fieldName, prefix)); + } + + [TestMethod] + [TestCategory("S3")] + public void StartsWithCondition_Constructor_NullPrefix_ThrowsArgumentNullException() + { + // Arrange + string fieldName = "key"; + string prefix = null; + + // Act & Assert + Assert.ThrowsException(() => + new StartsWithCondition(fieldName, prefix)); + } + + [TestMethod] + [TestCategory("S3")] + public void StartsWithCondition_Constructor_EmptyFieldName_ThrowsArgumentException() + { + // Arrange + string fieldName = ""; + string prefix = "user-uploads/"; + + // Act & Assert + Assert.ThrowsException(() => + new StartsWithCondition(fieldName, prefix)); + } + + [TestMethod] + [TestCategory("S3")] + public void StartsWithCondition_Constructor_EmptyPrefix_Succeeds() + { + // Arrange + string fieldName = "key"; + string prefix = ""; + + // Act + var condition = new StartsWithCondition(fieldName, prefix); + + // Assert + Assert.AreEqual(fieldName, condition.FieldName); + Assert.AreEqual(prefix, condition.Prefix); + } + + [TestMethod] + [TestCategory("S3")] + public void StartsWithCondition_WriteToJsonWriter_ProducesCorrectJson() + { + // Arrange + var condition = new StartsWithCondition("key", "user-uploads/"); + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + + // Act + writer.WriteStartArray(); + condition.WriteToJsonWriter(writer); + writer.WriteEndArray(); + writer.Flush(); + + // Assert + var json = Encoding.UTF8.GetString(stream.ToArray()); + var expectedJson = "[[\"starts-with\",\"$key\",\"user-uploads/\"]]"; + Assert.AreEqual(expectedJson, json); + } + + [TestMethod] + [TestCategory("S3")] + public void StartsWithCondition_WriteToJsonWriter_HandlesEmptyPrefix() + { + // Arrange + var condition = new StartsWithCondition("key", ""); + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + + // Act + writer.WriteStartArray(); + condition.WriteToJsonWriter(writer); + writer.WriteEndArray(); + writer.Flush(); + + // Assert + var json = Encoding.UTF8.GetString(stream.ToArray()); + var expectedJson = "[[\"starts-with\",\"$key\",\"\"]]"; + Assert.AreEqual(expectedJson, json); + } + + [TestMethod] + [TestCategory("S3")] + public void StartsWithCondition_WriteToJsonWriter_HandlesSpecialCharacters() + { + // Arrange + var condition = new StartsWithCondition("x-amz-meta-tag", "category/photos & videos"); + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + + // Act + writer.WriteStartArray(); + condition.WriteToJsonWriter(writer); + writer.WriteEndArray(); + writer.Flush(); + + // Assert + var json = Encoding.UTF8.GetString(stream.ToArray()); + Assert.IsTrue(json.Contains("starts-with")); + Assert.IsTrue(json.Contains("$x-amz-meta-tag")); + Assert.IsTrue(json.Contains("category/photos \\u0026 videos")); // JSON escaped + } + + #endregion + + #region ContentLengthRangeCondition Tests + + [TestMethod] + [TestCategory("S3")] + public void ContentLengthRangeCondition_Constructor_ValidParameters_SetsProperties() + { + // Arrange + long minLength = 1024; + long maxLength = 5242880; // 5MB + + // Act + var condition = new ContentLengthRangeCondition(minLength, maxLength); + + // Assert + Assert.AreEqual(minLength, condition.MinimumLength); + Assert.AreEqual(maxLength, condition.MaximumLength); + } + + [TestMethod] + [TestCategory("S3")] + public void ContentLengthRangeCondition_Constructor_NegativeMinimum_ThrowsArgumentException() + { + // Arrange + long minLength = -1; + long maxLength = 5242880; + + // Act & Assert + Assert.ThrowsException(() => + new ContentLengthRangeCondition(minLength, maxLength)); + } + + [TestMethod] + [TestCategory("S3")] + public void ContentLengthRangeCondition_Constructor_MaximumLessThanMinimum_ThrowsArgumentException() + { + // Arrange + long minLength = 5242880; + long maxLength = 1024; + + // Act & Assert + Assert.ThrowsException(() => + new ContentLengthRangeCondition(minLength, maxLength)); + } + + [TestMethod] + [TestCategory("S3")] + public void ContentLengthRangeCondition_Constructor_EqualMinimumAndMaximum_Succeeds() + { + // Arrange + long length = 1024; + + // Act + var condition = new ContentLengthRangeCondition(length, length); + + // Assert + Assert.AreEqual(length, condition.MinimumLength); + Assert.AreEqual(length, condition.MaximumLength); + } + + [TestMethod] + [TestCategory("S3")] + public void ContentLengthRangeCondition_Constructor_ZeroMinimum_Succeeds() + { + // Arrange + long minLength = 0; + long maxLength = 1024; + + // Act + var condition = new ContentLengthRangeCondition(minLength, maxLength); + + // Assert + Assert.AreEqual(minLength, condition.MinimumLength); + Assert.AreEqual(maxLength, condition.MaximumLength); + } + + [TestMethod] + [TestCategory("S3")] + public void ContentLengthRangeCondition_WriteToJsonWriter_ProducesCorrectJson() + { + // Arrange + var condition = new ContentLengthRangeCondition(1024, 5242880); + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + + // Act + writer.WriteStartArray(); + condition.WriteToJsonWriter(writer); + writer.WriteEndArray(); + writer.Flush(); + + // Assert + var json = Encoding.UTF8.GetString(stream.ToArray()); + var expectedJson = "[[\"content-length-range\",1024,5242880]]"; + Assert.AreEqual(expectedJson, json); + } + + [TestMethod] + [TestCategory("S3")] + public void ContentLengthRangeCondition_WriteToJsonWriter_HandlesLargeNumbers() + { + // Arrange + var condition = new ContentLengthRangeCondition(0, long.MaxValue); + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + + // Act + writer.WriteStartArray(); + condition.WriteToJsonWriter(writer); + writer.WriteEndArray(); + writer.Flush(); + + // Assert + var json = Encoding.UTF8.GetString(stream.ToArray()); + Assert.IsTrue(json.Contains("content-length-range")); + Assert.IsTrue(json.Contains("0")); + Assert.IsTrue(json.Contains(long.MaxValue.ToString())); + } + + #endregion + + #region Static Factory Methods Tests + + [TestMethod] + [TestCategory("S3")] + public void S3PostCondition_ExactMatch_CreatesExactMatchCondition() + { + // Arrange + string fieldName = "acl"; + string expectedValue = "public-read"; + + // Act + var condition = S3PostCondition.ExactMatch(fieldName, expectedValue); + + // Assert + Assert.IsInstanceOfType(condition, typeof(ExactMatchCondition)); + Assert.AreEqual(fieldName, condition.FieldName); + Assert.AreEqual(expectedValue, condition.ExpectedValue); + } + + [TestMethod] + [TestCategory("S3")] + public void S3PostCondition_StartsWith_CreatesStartsWithCondition() + { + // Arrange + string fieldName = "key"; + string prefix = "user-uploads/"; + + // Act + var condition = S3PostCondition.StartsWith(fieldName, prefix); + + // Assert + Assert.IsInstanceOfType(condition, typeof(StartsWithCondition)); + Assert.AreEqual(fieldName, condition.FieldName); + Assert.AreEqual(prefix, condition.Prefix); + } + + [TestMethod] + [TestCategory("S3")] + public void S3PostCondition_ContentLengthRange_CreatesContentLengthRangeCondition() + { + // Arrange + long minLength = 1024; + long maxLength = 5242880; + + // Act + var condition = S3PostCondition.ContentLengthRange(minLength, maxLength); + + // Assert + Assert.IsInstanceOfType(condition, typeof(ContentLengthRangeCondition)); + Assert.AreEqual(minLength, condition.MinimumLength); + Assert.AreEqual(maxLength, condition.MaximumLength); + } + + [TestMethod] + [TestCategory("S3")] + public void S3PostCondition_FactoryMethods_ValidateParameters() + { + // Test ExactMatch validation + Assert.ThrowsException(() => + S3PostCondition.ExactMatch(null, "value")); + Assert.ThrowsException(() => + S3PostCondition.ExactMatch("field", null)); + + // Test StartsWith validation + Assert.ThrowsException(() => + S3PostCondition.StartsWith(null, "prefix")); + Assert.ThrowsException(() => + S3PostCondition.StartsWith("field", null)); + + // Test ContentLengthRange validation + Assert.ThrowsException(() => + S3PostCondition.ContentLengthRange(-1, 100)); + Assert.ThrowsException(() => + S3PostCondition.ContentLengthRange(100, 50)); + } + + #endregion + + #region Common Scenarios Tests + + [TestMethod] + [TestCategory("S3")] + public void S3PostCondition_CommonScenarios_ProduceExpectedJson() + { + // Arrange - Create conditions for a typical photo upload scenario + var bucketCondition = S3PostCondition.ExactMatch("bucket", "my-photo-uploads"); + var aclCondition = S3PostCondition.ExactMatch("acl", "public-read"); + var keyCondition = S3PostCondition.StartsWith("key", "photos/2024/"); + var contentTypeCondition = S3PostCondition.StartsWith("Content-Type", "image/"); + var sizeCondition = S3PostCondition.ContentLengthRange(1024, 10 * 1024 * 1024); // 1KB to 10MB + var categoryCondition = S3PostCondition.ExactMatch("x-amz-meta-category", "user-uploads"); + + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + + // Act - Write a complete policy conditions array + writer.WriteStartArray(); + + bucketCondition.WriteToJsonWriter(writer); + aclCondition.WriteToJsonWriter(writer); + keyCondition.WriteToJsonWriter(writer); + contentTypeCondition.WriteToJsonWriter(writer); + sizeCondition.WriteToJsonWriter(writer); + categoryCondition.WriteToJsonWriter(writer); + + writer.WriteEndArray(); + writer.Flush(); + + // Assert + var json = Encoding.UTF8.GetString(stream.ToArray()); + + // Verify bucket condition + Assert.IsTrue(json.Contains("{\"bucket\":\"my-photo-uploads\"}")); + + // Verify ACL condition + Assert.IsTrue(json.Contains("{\"acl\":\"public-read\"}")); + + // Verify key starts-with condition + Assert.IsTrue(json.Contains("[\"starts-with\",\"$key\",\"photos/2024/\"]")); + + // Verify content-type starts-with condition + Assert.IsTrue(json.Contains("[\"starts-with\",\"$Content-Type\",\"image/\"]")); + + // Verify content-length-range condition + Assert.IsTrue(json.Contains("[\"content-length-range\",1024,10485760]")); + + // Verify metadata condition + Assert.IsTrue(json.Contains("{\"x-amz-meta-category\":\"user-uploads\"}")); + } + + [TestMethod] + [TestCategory("S3")] + public void S3PostCondition_UnicodeHandling_ProducesValidJson() + { + // Arrange - Test with Unicode characters + var condition = S3PostCondition.ExactMatch("x-amz-meta-title", "文档上传 - Document Upload"); + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + + // Act + condition.WriteToJsonWriter(writer); + writer.Flush(); + + // Assert + var json = Encoding.UTF8.GetString(stream.ToArray()); + + // Verify the JSON is valid and contains the Unicode content + Assert.IsTrue(json.Contains("x-amz-meta-title")); + + // Parse to ensure it's valid JSON + var document = JsonDocument.Parse(json); + var element = document.RootElement.GetProperty("x-amz-meta-title"); + Assert.AreEqual("文档上传 - Document Upload", element.GetString()); + } + + #endregion + } +}