diff --git a/generator/.DevConfigs/48cf8831-8233-4a7e-853c-5352f8ca948d.json b/generator/.DevConfigs/48cf8831-8233-4a7e-853c-5352f8ca948d.json new file mode 100644 index 000000000000..dec1f35d056f --- /dev/null +++ b/generator/.DevConfigs/48cf8831-8233-4a7e-853c-5352f8ca948d.json @@ -0,0 +1,11 @@ +{ + "services": [ + { + "serviceName": "S3", + "type": "minor", + "changeLogMessages": [ + "Implement presigned POST urls" + ] + } + ] +} diff --git a/generator/ServiceClientGeneratorLib/Generators/Endpoints/EndpointResolver.cs b/generator/ServiceClientGeneratorLib/Generators/Endpoints/EndpointResolver.cs index 0181df0b938c..1b29112ca63d 100644 --- a/generator/ServiceClientGeneratorLib/Generators/Endpoints/EndpointResolver.cs +++ b/generator/ServiceClientGeneratorLib/Generators/Endpoints/EndpointResolver.cs @@ -19,7 +19,7 @@ namespace ServiceClientGenerator.Generators.Endpoints /// Class to produce the template output /// - #line 1 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 1 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "17.0.0.0")] public partial class EndpointResolver : BaseGenerator { @@ -30,7 +30,7 @@ public partial class EndpointResolver : BaseGenerator public override string TransformText() { - #line 7 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 7 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" AddLicenseHeader(); @@ -40,7 +40,7 @@ public override string TransformText() this.Write("\r\nusing System;\r\nusing System.Linq;\r\nusing System.Collections.Generic;\r\nusing Ama" + "zon."); - #line 14 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 14 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" this.Write(this.ToStringHelper.ToStringWithCulture(Config.ServiceNameRoot)); #line default @@ -48,21 +48,21 @@ public override string TransformText() this.Write(".Model;\r\nusing Amazon.Runtime;\r\nusing Amazon.Runtime.Internal;\r\nusing Amazon.Runt" + "ime.Endpoints;\r\nusing Amazon.Util;\r\nusing "); - #line 19 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 19 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" this.Write(this.ToStringHelper.ToStringWithCulture(Config.Namespace)); #line default #line hidden this.Write(".Endpoints;\r\n\r\n#pragma warning disable 1591\r\n\r\nnamespace "); - #line 23 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 23 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" this.Write(this.ToStringHelper.ToStringWithCulture(Config.Namespace)); #line default #line hidden this.Write(".Internal\r\n{\r\n /// \r\n /// Amazon "); - #line 26 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 26 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" this.Write(this.ToStringHelper.ToStringWithCulture(this.Config.ClassName)); #line default @@ -70,14 +70,14 @@ public override string TransformText() this.Write(" endpoint resolver.\r\n /// Custom PipelineHandler responsible for resolving end" + "point and setting authentication parameters for "); - #line 27 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 27 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" this.Write(this.ToStringHelper.ToStringWithCulture(this.Config.ClassName)); #line default #line hidden this.Write(" service requests.\r\n /// Collects values for "); - #line 28 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 28 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" this.Write(this.ToStringHelper.ToStringWithCulture(this.Config.ClassName)); #line default @@ -85,7 +85,7 @@ public override string TransformText() this.Write("EndpointParameters and then tries to resolve endpoint by calling \r\n /// Resolv" + "eEndpoint method on GlobalEndpoints.Provider if present, otherwise uses "); - #line 29 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 29 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" this.Write(this.ToStringHelper.ToStringWithCulture(this.Config.ClassName)); #line default @@ -93,7 +93,7 @@ public override string TransformText() this.Write("EndpointProvider.\r\n /// Responsible for setting authentication and http header" + "s provided by resolved endpoint.\r\n /// \r\n public class Amazon"); - #line 32 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 32 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" this.Write(this.ToStringHelper.ToStringWithCulture(Config.ClassName)); #line default @@ -102,7 +102,7 @@ public override string TransformText() "erviceSpecificHandler(IExecutionContext executionContext, EndpointParameters par" + "ameters)\r\n {\r\n"); - #line 36 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 36 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" if (Config.ServiceId == "S3") { #line default @@ -123,21 +123,21 @@ public override string TransformText() } "); - #line 51 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 51 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" } #line default #line hidden this.Write("\r\n"); - #line 53 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 53 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" if (!this.dontInjectHostPrefixForServices.Contains(Config.ServiceId)) { #line default #line hidden this.Write(" InjectHostPrefix(executionContext.RequestContext);\r\n"); - #line 55 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 55 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" } #line default @@ -145,35 +145,35 @@ public override string TransformText() this.Write(" }\r\n\r\n protected override EndpointParameters MapEndpointsParameters" + "(IRequestContext requestContext)\r\n {\r\n var config = (Amazon"); - #line 60 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 60 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" this.Write(this.ToStringHelper.ToStringWithCulture(Config.ClassName)); #line default #line hidden this.Write("Config)requestContext.ClientConfig;\r\n var result = new "); - #line 61 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 61 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" this.Write(this.ToStringHelper.ToStringWithCulture(Config.ClassName)); #line default #line hidden this.Write("EndpointParameters();\r\n"); - #line 62 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 62 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" this.Write(this.ToStringHelper.ToStringWithCulture(this.AssignBuiltins())); #line default #line hidden this.Write("\r\n"); - #line 63 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 63 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" this.Write(this.ToStringHelper.ToStringWithCulture(this.AssignClientContext())); #line default #line hidden this.Write("\r\n"); - #line 64 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 64 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" if (Config.EndpointsRuleSet.parameters.ContainsKey("Region")) { #line default @@ -208,13 +208,13 @@ public override string TransformText() "); - #line 93 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 93 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" } #line default #line hidden - #line 94 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 94 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" // GetACL and PutACL are deprecated in V4 and may be removed in the future @@ -222,7 +222,7 @@ public override string TransformText() #line default #line hidden - #line 97 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 97 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" if (Config.ClassName == "S3") { #line default @@ -234,6 +234,13 @@ public override string TransformText() result.Bucket = request.BucketName; return result; } + // Special handling of CreatePresignedPostRequest + if (requestContext.Request.RequestName == ""CreatePresignedPostRequest"") + { + var request = (CreatePresignedPostRequest)requestContext.Request.OriginalRequest; + result.Bucket = request.BucketName; + return result; + } if (requestContext.RequestName == ""GetACLRequest"") { result.UseS3ExpressControlEndpoint = true; var request = (GetACLRequest)requestContext.OriginalRequest; @@ -248,14 +255,14 @@ public override string TransformText() } "); - #line 117 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 124 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" } #line default #line hidden this.Write("\r\n // Assign staticContextParams and contextParam per operation\r\n"); - #line 120 "C:\Dev\worktrees\nosigv2\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" + #line 127 "C:\dev\repos\aws-sdk-net\generator\ServiceClientGeneratorLib\Generators\Endpoints\EndpointResolver.tt" this.Write(this.ToStringHelper.ToStringWithCulture(this.AssignOperationContext())); #line default diff --git a/generator/ServiceClientGeneratorLib/Generators/Endpoints/EndpointResolver.tt b/generator/ServiceClientGeneratorLib/Generators/Endpoints/EndpointResolver.tt index a10658df7944..fe1d59d8f9e8 100644 --- a/generator/ServiceClientGeneratorLib/Generators/Endpoints/EndpointResolver.tt +++ b/generator/ServiceClientGeneratorLib/Generators/Endpoints/EndpointResolver.tt @@ -102,6 +102,13 @@ namespace <#=Config.Namespace#>.Internal result.Bucket = request.BucketName; return result; } + // Special handling of CreatePresignedPostRequest + if (requestContext.Request.RequestName == "CreatePresignedPostRequest") + { + var request = (CreatePresignedPostRequest)requestContext.Request.OriginalRequest; + result.Bucket = request.BucketName; + return result; + } if (requestContext.RequestName == "GetACLRequest") { result.UseS3ExpressControlEndpoint = true; var request = (GetACLRequest)requestContext.OriginalRequest; diff --git a/sdk/src/Services/S3/Custom/AmazonS3Client.Extensions.cs b/sdk/src/Services/S3/Custom/AmazonS3Client.Extensions.cs index 8cb5df66e327..f05f900342f9 100644 --- a/sdk/src/Services/S3/Custom/AmazonS3Client.Extensions.cs +++ b/sdk/src/Services/S3/Custom/AmazonS3Client.Extensions.cs @@ -13,13 +13,20 @@ * 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; using System.Threading.Tasks; +#if !NETFRAMEWORK +using ThirdParty.RuntimeBackports; +#endif + using Amazon.Runtime; using Amazon.Runtime.Internal; using Amazon.Runtime.Internal.Auth; @@ -564,6 +571,292 @@ public async Task GetPreSignedURLAsync(GetPreSignedUrlRequest request) } #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); + } + + /// + /// 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); + } + + /// + /// 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 expiration > 7 days + var secondsUntilExpiration = Convert.ToInt64((request.Expires.Value.ToUniversalTime() - + AWSSDKUtils.CorrectedUtcNow).TotalSeconds); + if (secondsUntilExpiration > AWS4PreSignedUrlSigner.MaxAWS4PreSignedUrlExpiry) + { + var msg = string.Format(CultureInfo.InvariantCulture, + "The maximum expiry period for a presigned url using AWS4 signing is {0} seconds", + AWS4PreSignedUrlSigner.MaxAWS4PreSignedUrlExpiry); + throw new ArgumentException(msg); + } + + // 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 immutable AWS credentials. + /// A CreatePresignedPostResponse containing the URL and form fields for the POST request. + private CreatePresignedPostResponse BuildPresignedPostResponse(CreatePresignedPostRequest request, IRequest irequest, ImmutableCredentials 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(); + + // Use ComposeUrl to build the proper URL (same approach as GetPreSignedURL) + response.Url = ComposeUrl(irequest).AbsoluteUri; + + // 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 immutableCredentials = credentials.GetCredentials(); + var irequest = CreateAndProcessRequest(request); + return BuildPresignedPostResponse(request, irequest, immutableCredentials); + } + + /// + /// 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, immutableCredentials); + } + + /// + /// 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 uses root resource path + 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. + /// + /// 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)) + { + // Check if key ends with ${filename} and add special handling + if (request.Key.EndsWith("${filename}", StringComparison.Ordinal)) + { + // Extract the prefix before ${filename} + // https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-HTTPPOSTForms.html#sigv4-HTTPPOSTFormFields + /* + The variable ${filename} is automatically replaced with the name of the file provided by the user and is recognized by all form fields. + If the browser or client provides a full or partial path to the file, + only the text following the last slash (/) or backslash (\) is used (for example, C:\Program Files\directory1\file.txt is interpreted as file.txt). + If no file or file name is provided, the variable is replaced with an empty string. + */ + string keyPrefix = request.Key.Substring(0, request.Key.LastIndexOf("${filename}", StringComparison.Ordinal)); + + // Add a starts-with condition instead of exact match + writer.WriteStartArray(); + writer.WriteStringValue("starts-with"); + writer.WriteStringValue("$key"); + writer.WriteStringValue(keyPrefix); + writer.WriteEndArray(); + } + else + { + // Regular exact match condition for keys without ${filename} + writer.WriteStartObject(); + writer.WriteString("key", request.Key); + writer.WriteEndObject(); + } + } + + // Track field conditions to avoid duplicates + var fieldConditions = new HashSet(); + + // Add field conditions + foreach (var field in request.Fields) + { + writer.WriteStartObject(); + writer.WriteString(field.Key, field.Value); + writer.WriteEndObject(); + + // Track this field+value combination + fieldConditions.Add($"{field.Key}:{field.Value}"); + } + + // Add custom conditions, skipping duplicates of field conditions + foreach (var condition in request.Conditions) + { + // Skip ExactMatch conditions that duplicate field conditions + if (condition is ExactMatchCondition exactMatch) + { + var conditionKey = $"{exactMatch.FieldName}:{exactMatch.ExpectedValue}"; + if (fieldConditions.Contains(conditionKey)) + { + continue; // Skip duplicate + } + } + + 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/Model/CreatePresignedPostRequest.cs b/sdk/src/Services/S3/Custom/Model/CreatePresignedPostRequest.cs new file mode 100644 index 000000000000..0f58d1668767 --- /dev/null +++ b/sdk/src/Services/S3/Custom/Model/CreatePresignedPostRequest.cs @@ -0,0 +1,67 @@ +/* + * 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; +using System.Threading.Tasks; +using Amazon.Runtime; +using Amazon.Util; + +namespace Amazon.S3.Model +{ + /// + /// 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. + /// Defaults to one hour from the time the request is created. + /// + 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/Model/CreatePresignedPostResponse.cs b/sdk/src/Services/S3/Custom/Model/CreatePresignedPostResponse.cs new file mode 100644 index 000000000000..da77bf7c8e02 --- /dev/null +++ b/sdk/src/Services/S3/Custom/Model/CreatePresignedPostResponse.cs @@ -0,0 +1,50 @@ +/* + * 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; +using System.Threading.Tasks; + +namespace Amazon.S3.Model +{ + /// + /// 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/Model/S3PostCondition.cs b/sdk/src/Services/S3/Custom/Model/S3PostCondition.cs new file mode 100644 index 000000000000..736b88ef60ca --- /dev/null +++ b/sdk/src/Services/S3/Custom/Model/S3PostCondition.cs @@ -0,0 +1,449 @@ +/* + * 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.Text.Json; + +namespace Amazon.S3.Model +{ + /// + /// 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. + /// + /// A for the specified size range. + /// + /// Thrown when is negative, or when + /// is less than . + /// + /// + /// + /// // Allow files between 1KB and 5MB + /// 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. + /// + /// + /// 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"). + /// + 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. + /// + /// + /// 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/src/Services/S3/Custom/Util/S3PostUploadSignedPolicy.cs b/sdk/src/Services/S3/Custom/Util/S3PostUploadSignedPolicy.cs index 456711bc0fa4..b29584333ee6 100644 --- a/sdk/src/Services/S3/Custom/Util/S3PostUploadSignedPolicy.cs +++ b/sdk/src/Services/S3/Custom/Util/S3PostUploadSignedPolicy.cs @@ -101,10 +101,22 @@ private static string /// Service region endpoint. /// A signed policy object for use with an S3PostUploadRequest. public static S3PostUploadSignedPolicy GetSignedPolicy(string policy, AWSCredentials credentials, RegionEndpoint region) + { + ImmutableCredentials iCreds = credentials.GetCredentials(); + return GetSignedPolicy(policy, iCreds, region); + } + + /// + /// Given a policy and immutable credentials, produce a S3PostUploadSignedPolicy. + /// + /// JSON string representing the policy to sign + /// Immutable credentials to sign the policy with + /// Service region endpoint. + /// A signed policy object for use with an S3PostUploadRequest. + internal static S3PostUploadSignedPolicy GetSignedPolicy(string policy, ImmutableCredentials iCreds, RegionEndpoint region) { var signedAt = AWSSDKUtils.CorrectedUtcNow; - ImmutableCredentials iCreds = credentials.GetCredentials(); var algorithm = "AWS4-HMAC-SHA256"; var dateStamp = Runtime.Internal.Auth.AWS4Signer.FormatDateTime(signedAt, AWSSDKUtils.ISO8601BasicDateFormat); var dateTimeStamp = Runtime.Internal.Auth.AWS4Signer.FormatDateTime(signedAt, AWSSDKUtils.ISO8601BasicDateTimeFormat); diff --git a/sdk/src/Services/S3/Generated/Internal/AmazonS3EndpointResolver.cs b/sdk/src/Services/S3/Generated/Internal/AmazonS3EndpointResolver.cs index 7fde0597b149..90c232b0e2dd 100644 --- a/sdk/src/Services/S3/Generated/Internal/AmazonS3EndpointResolver.cs +++ b/sdk/src/Services/S3/Generated/Internal/AmazonS3EndpointResolver.cs @@ -110,6 +110,13 @@ protected override EndpointParameters MapEndpointsParameters(IRequestContext req result.Bucket = request.BucketName; return result; } + // Special handling of CreatePresignedPostRequest + if (requestContext.Request.RequestName == "CreatePresignedPostRequest") + { + var request = (CreatePresignedPostRequest)requestContext.Request.OriginalRequest; + result.Bucket = request.BucketName; + return result; + } if (requestContext.RequestName == "GetACLRequest") { result.UseS3ExpressControlEndpoint = true; var request = (GetACLRequest)requestContext.OriginalRequest; diff --git a/sdk/test/IntegrationTests/AWSSDK.IntegrationTests.NetFramework.csproj b/sdk/test/IntegrationTests/AWSSDK.IntegrationTests.NetFramework.csproj index 4e5d9b0f337a..1df6614ef6dc 100644 --- a/sdk/test/IntegrationTests/AWSSDK.IntegrationTests.NetFramework.csproj +++ b/sdk/test/IntegrationTests/AWSSDK.IntegrationTests.NetFramework.csproj @@ -51,6 +51,7 @@ + diff --git a/sdk/test/Services/S3/IntegrationTests/AWSSDK.IntegrationTests.S3.NetFramework.csproj b/sdk/test/Services/S3/IntegrationTests/AWSSDK.IntegrationTests.S3.NetFramework.csproj index 341ae50f856c..09d7ecd49090 100644 --- a/sdk/test/Services/S3/IntegrationTests/AWSSDK.IntegrationTests.S3.NetFramework.csproj +++ b/sdk/test/Services/S3/IntegrationTests/AWSSDK.IntegrationTests.S3.NetFramework.csproj @@ -1,4 +1,4 @@ - + net472 $(DefineConstants);DEBUG;TRACE;BCL;ASYNC_AWAIT;LOCAL_FILE @@ -52,6 +52,8 @@ + + diff --git a/sdk/test/Services/S3/IntegrationTests/CreatePresignedPostTests.cs b/sdk/test/Services/S3/IntegrationTests/CreatePresignedPostTests.cs new file mode 100644 index 000000000000..f40533a7c0a5 --- /dev/null +++ b/sdk/test/Services/S3/IntegrationTests/CreatePresignedPostTests.cs @@ -0,0 +1,805 @@ +/* + * 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 Amazon; +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Model; +using Amazon.S3.Util; +using Amazon.Util; +using AWSSDK_DotNet.IntegrationTests.Utils; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; + + +namespace AWSSDK_DotNet.IntegrationTests.Tests.S3 +{ + /// + /// Integration tests for CreatePresignedPost functionality + /// + [TestClass] + public class CreatePresignedPostTests : TestBase + { + // Test result classes for better structure + private class UploadResult + { + public bool IsSuccessful { get; set; } + public HttpStatusCode StatusCode { get; set; } + public string ResponseText { get; set; } + } + + private const string TestContent = "This is the content body!"; + private const string TestKey = "presigned-post-key"; + + private class PresignedPostTestParameters + { + public RegionEndpoint Region { get; set; } + public DateTime Expiration { get; set; } + public string BucketName { get; set; } + public Dictionary Fields { get; set; } + public List Conditions { get; set; } + } + + [TestMethod] + [TestCategory("S3")] + [TestCategory("RequiresIAMUser")] + public void USEastUnder7Days() + { + TestPresignedPost(new PresignedPostTestParameters + { + Region = RegionEndpoint.USEast1, + Expiration = AWSSDKUtils.CorrectedUtcNow.AddDays(7).AddHours(-2) + }); + + TestPresignedPostWithSessionToken(new PresignedPostTestParameters + { + Region = RegionEndpoint.USEast1, + Expiration = AWSSDKUtils.CorrectedUtcNow.AddDays(7).AddHours(-2) + }); + } + + [TestMethod] + [TestCategory("S3")] + [TestCategory("RequiresIAMUser")] + public void USEastOver7Days() + { + // Unlike GetPreSignedUrl, CreatePresignedPost always uses SigV4 and should throw an exception for expirations > 7 days + AssertExtensions.ExpectException(() => + { + TestPresignedPost(new PresignedPostTestParameters + { + Region = RegionEndpoint.USEast1, + Expiration = AWSSDKUtils.CorrectedUtcNow.AddDays(7).AddHours(2) + }); + }, typeof(ArgumentException), "The maximum expiry period for a presigned url using AWS4 signing is 604800 seconds"); + + AssertExtensions.ExpectException(() => + { + TestPresignedPostWithSessionToken(new PresignedPostTestParameters + { + Region = RegionEndpoint.USEast1, + Expiration = AWSSDKUtils.CorrectedUtcNow.AddDays(7).AddHours(2) + }); + }, typeof(ArgumentException), "The maximum expiry period for a presigned url using AWS4 signing is 604800 seconds"); + } + + [TestMethod] + [TestCategory("S3")] + [TestCategory("RequiresIAMUser")] + public void WithCustomConditions() + { + var testParams = new PresignedPostTestParameters + { + Region = RegionEndpoint.USEast1, + Expiration = AWSSDKUtils.CorrectedUtcNow.AddHours(1), + Fields = new Dictionary + { + // Include Content-Type in Fields even with a starts-with condition + // This matches JavaScript SDK behavior - fields and conditions can coexist + { "Content-Type", "text/plain" } + }, + Conditions = new List + { + S3PostCondition.StartsWith("Content-Type", "text/"), + S3PostCondition.ContentLengthRange(1, 1048576) // 1 byte to 1 MB + } + }; + + TestPresignedPostWithConditions(testParams); + } + + [TestMethod] + [TestCategory("S3")] + [TestCategory("RequiresIAMUser")] + public void WithContentTypeFieldAndStartsWithCondition() + { + var testParams = new PresignedPostTestParameters + { + Region = RegionEndpoint.USEast1, + Expiration = AWSSDKUtils.CorrectedUtcNow.AddHours(1), + Fields = new Dictionary + { + // Include Content-Type in Fields, matching JavaScript SDK behavior + { "Content-Type", "text/plain" }, + { "success_action_status", "201" } + }, + Conditions = new List + { + // Also add a starts-with condition for Content-Type + S3PostCondition.StartsWith("Content-Type", "text/"), + S3PostCondition.ContentLengthRange(1, 1048576) // 1 byte to 1 MB + } + }; + + TestPresignedPostWithMixedContentType(testParams); + } + + [TestMethod] + [TestCategory("S3")] + [TestCategory("RequiresIAMUser")] + public void FilenameVariableHandling() + { + var testParams = new PresignedPostTestParameters + { + Region = RegionEndpoint.USEast1, + Expiration = AWSSDKUtils.CorrectedUtcNow.AddHours(1) + }; + + TestPresignedPostWithFilenameVariable(testParams); + } + + private void TestPresignedPost(PresignedPostTestParameters testParams) + { + var client = new AmazonS3Client(testParams.Region); + try + { + // Create regular bucket + testParams.BucketName = S3TestUtils.CreateBucketWithWait(client); + + AssertPresignedPost(client, testParams); + } + finally + { + if (testParams.BucketName != null) + AmazonS3Util.DeleteS3BucketWithObjects(client, testParams.BucketName); + } + } + + private void TestPresignedPostWithSessionToken(PresignedPostTestParameters testParams) + { + using (var sts = new Amazon.SecurityToken.AmazonSecurityTokenServiceClient()) + { + AWSCredentials credentials = sts.GetSessionToken().Credentials; + var client = new AmazonS3Client(credentials, testParams.Region); + try + { + // Create regular bucket + testParams.BucketName = S3TestUtils.CreateBucketWithWait(client); + + AssertPresignedPost(client, testParams); + } + finally + { + if (testParams.BucketName != null) + AmazonS3Util.DeleteS3BucketWithObjects(client, testParams.BucketName); + } + } + } + + private void TestPresignedPostWithFields(PresignedPostTestParameters testParams) + { + var client = new AmazonS3Client(testParams.Region); + try + { + // Create regular bucket + testParams.BucketName = S3TestUtils.CreateBucketWithWait(client); + + AssertPresignedPostWithFields(client, testParams); + } + finally + { + if (testParams.BucketName != null) + AmazonS3Util.DeleteS3BucketWithObjects(client, testParams.BucketName); + } + } + + private void TestPresignedPostWithConditions(PresignedPostTestParameters testParams) + { + var client = new AmazonS3Client(testParams.Region); + try + { + // Create regular bucket + testParams.BucketName = S3TestUtils.CreateBucketWithWait(client); + + AssertPresignedPostWithConditions(client, testParams); + } + finally + { + if (testParams.BucketName != null) + AmazonS3Util.DeleteS3BucketWithObjects(client, testParams.BucketName); + } + } + + // Helper methods for creating and working with presigned POST URLs + private CreatePresignedPostResponse GeneratePresignedPostRequest( + AmazonS3Client client, + string bucketName, + string objectKey, + DateTime expiration, + Dictionary fields = null, + List conditions = null) + { + var request = new CreatePresignedPostRequest + { + BucketName = bucketName, + Key = objectKey, + Expires = expiration + }; + + // Add custom fields if provided + if (fields != null) + { + foreach (var field in fields) + { + request.Fields.Add(field.Key, field.Value); + } + } + + // Add conditions if provided + if (conditions != null) + { + foreach (var condition in conditions) + { + request.Conditions.Add(condition); + } + } + + return client.CreatePresignedPost(request); + } + + // Validates that Content-Type field exists with expected value + private void ValidateContentTypeFieldPresent(CreatePresignedPostResponse response, string expectedContentType) + { + Assert.IsTrue(response.Fields.ContainsKey("Content-Type"), + "Content-Type should be included in response fields even with a starts-with condition"); + Assert.AreEqual(expectedContentType, response.Fields["Content-Type"]); + } + + // Performs an upload with valid Content-Type + private UploadResult PerformUpload( + string url, + Dictionary fields, + string content, + string objectKey, + string contentType) + { + var formData = new MultipartFormDataContent(); + + // Add all form fields + foreach (var field in fields) + { + formData.Add(new StringContent(field.Value), field.Key); + } + + // Add file content with proper Content-Type + var fileContent = new ByteArrayContent(Encoding.UTF8.GetBytes(content)); + fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType); + formData.Add(fileContent, "file", objectKey); + + using (var httpClient = new System.Net.Http.HttpClient()) + { + // Send the POST request + var httpResponse = httpClient.PostAsync(url, formData).Result; + + return new UploadResult + { + IsSuccessful = httpResponse.IsSuccessStatusCode, + StatusCode = httpResponse.StatusCode, + ResponseText = httpResponse.Content.ReadAsStringAsync().Result + }; + } + } + + // Validates that the object was uploaded correctly + private void ValidateObjectContent(AmazonS3Client client, string bucketName, string objectKey, string expectedContent) + { + var getObjectResponse = client.GetObject(bucketName, objectKey); + using (var reader = new StreamReader(getObjectResponse.ResponseStream)) + { + var content = reader.ReadToEnd(); + Assert.AreEqual(expectedContent, content, "Object content does not match expected content"); + } + } + + // Performs an upload with invalid Content-Type to test condition enforcement + private UploadResult PerformInvalidContentTypeUpload( + string url, + Dictionary fields, + string content, + string objectKey) + { + var formData = new MultipartFormDataContent(); + + // Add all fields from response + foreach (var field in fields) + { + if (field.Key == "Content-Type") + { + // Override Content-Type with an invalid one + formData.Add(new StringContent("image/jpeg"), field.Key); + } + else + { + formData.Add(new StringContent(field.Value), field.Key); + } + } + + // Add file with invalid Content-Type header + var invalidFileContent = new ByteArrayContent(Encoding.UTF8.GetBytes(content)); + invalidFileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/jpeg"); + formData.Add(invalidFileContent, "file", objectKey); + + using (var httpClient = new System.Net.Http.HttpClient()) + { + var httpResponse = httpClient.PostAsync(url, formData).Result; + + return new UploadResult + { + IsSuccessful = httpResponse.IsSuccessStatusCode, + StatusCode = httpResponse.StatusCode, + ResponseText = httpResponse.Content.ReadAsStringAsync().Result + }; + } + } + + private void TestPresignedPostWithMixedContentType(PresignedPostTestParameters testParams) + { + var client = new AmazonS3Client(testParams.Region); + try + { + // Create regular bucket + testParams.BucketName = S3TestUtils.CreateBucketWithWait(client); + + // Create a unique object key + string objectKey = TestKey + DateTime.UtcNow.Ticks; + + // Step 1: Generate presigned POST response + var response = GeneratePresignedPostRequest( + client, + testParams.BucketName, + objectKey, + testParams.Expiration, + testParams.Fields, + testParams.Conditions); + + // Step 2: Verify Content-Type field is included in response + ValidateContentTypeFieldPresent(response, "text/plain"); + + // Step 3: Perform upload with valid Content-Type + var uploadResult = PerformUpload( + response.Url, + response.Fields, + TestContent, + objectKey, + "text/plain"); + + // Step 4: Verify upload was successful + Assert.IsTrue(uploadResult.IsSuccessful, + $"Upload failed with status code {uploadResult.StatusCode}"); + + // Step 5: Verify uploaded content + ValidateObjectContent(client, testParams.BucketName, objectKey, TestContent); + + // Step 6: Clean up for next test + client.DeleteObject(testParams.BucketName, objectKey); + + // Step 7: Test with invalid Content-Type + var invalidResult = PerformInvalidContentTypeUpload( + response.Url, + response.Fields, + TestContent, + objectKey); + + // Step 8: Verify upload with invalid Content-Type was rejected + Assert.AreEqual(HttpStatusCode.Forbidden, invalidResult.StatusCode, + "Upload with invalid Content-Type should be rejected"); + } + finally + { + if (testParams.BucketName != null) + AmazonS3Util.DeleteS3BucketWithObjects(client, testParams.BucketName); + } + } + + private void AssertPresignedPost(AmazonS3Client client, PresignedPostTestParameters testParams) + { + string objectKey = TestKey + DateTime.UtcNow.Ticks; + + // Generate presigned POST response + var request = new CreatePresignedPostRequest + { + BucketName = testParams.BucketName, + Key = objectKey, + Expires = testParams.Expiration + }; + + var response = client.CreatePresignedPost(request); + + // Verify required fields + Assert.IsNotNull(response.Url); + Assert.IsNotNull(response.Fields); + Assert.IsTrue(response.Fields.ContainsKey("key")); + Assert.IsTrue(response.Fields.ContainsKey("Policy")); + Assert.IsTrue(response.Fields.ContainsKey("x-amz-algorithm")); + Assert.IsTrue(response.Fields.ContainsKey("x-amz-credential")); + Assert.IsTrue(response.Fields.ContainsKey("x-amz-date")); + Assert.IsTrue(response.Fields.ContainsKey("x-amz-signature")); + + // Use the presigned post form to upload a file + var formData = new MultipartFormDataContent(); + + // Add all form fields + foreach (var field in response.Fields) + { + formData.Add(new StringContent(field.Value), field.Key); + } + + // Add file content + formData.Add(new ByteArrayContent(Encoding.UTF8.GetBytes(TestContent)), "file", objectKey); + + // Create and configure the HttpClient + using (var httpClient = new System.Net.Http.HttpClient()) + { + // Send the POST request + var httpResponse = httpClient.PostAsync(response.Url, formData).Result; + + // Verify the upload was successful + Assert.AreEqual(HttpStatusCode.NoContent, httpResponse.StatusCode); + + // Verify the uploaded object exists and has the correct content + var getObjectResponse = client.GetObject(testParams.BucketName, objectKey); + using (var reader = new StreamReader(getObjectResponse.ResponseStream)) + { + var content = reader.ReadToEnd(); + Assert.AreEqual(TestContent, content); + } + } + } + + private void AssertPresignedPostWithFields(AmazonS3Client client, PresignedPostTestParameters testParams) + { + string objectKey = TestKey + DateTime.UtcNow.Ticks; + + // Generate presigned POST response + var request = new CreatePresignedPostRequest + { + BucketName = testParams.BucketName, + Key = objectKey, + Expires = testParams.Expiration + }; + + // Add custom fields + foreach (var field in testParams.Fields) + { + request.Fields.Add(field.Key, field.Value); + } + + var response = client.CreatePresignedPost(request); + + // Verify all fields are present in the response + foreach (var field in testParams.Fields) + { + Assert.IsTrue(response.Fields.ContainsKey(field.Key)); + Assert.AreEqual(field.Value, response.Fields[field.Key]); + } + + // Use the presigned post form to upload a file + var formData = new MultipartFormDataContent(); + + // Add all form fields + foreach (var field in response.Fields) + { + formData.Add(new StringContent(field.Value), field.Key); + } + + // Add file content + formData.Add(new ByteArrayContent(Encoding.UTF8.GetBytes(TestContent)), "file", objectKey); + + // Create and configure the HttpClient + using (var httpClient = new System.Net.Http.HttpClient()) + { + // Send the POST request + var httpResponse = httpClient.PostAsync(response.Url, formData).Result; + + // Verify the upload was successful + Assert.IsTrue(httpResponse.IsSuccessStatusCode); + + // Verify the uploaded object exists and has the correct content + var getObjectResponse = client.GetObject(testParams.BucketName, objectKey); + using (var reader = new StreamReader(getObjectResponse.ResponseStream)) + { + var content = reader.ReadToEnd(); + Assert.AreEqual(TestContent, content); + } + + if (testParams.Fields.ContainsKey("x-amz-meta-original-filename")) + { + var headObjectResponse = client.GetObjectMetadata(testParams.BucketName, objectKey); + + // Check if the metadata contains the key using the Keys collection + Assert.IsTrue(headObjectResponse.Metadata.Keys.Contains("original-filename"), + "Metadata should contain 'original-filename'"); + Assert.AreEqual(testParams.Fields["x-amz-meta-original-filename"], + headObjectResponse.Metadata["original-filename"]); + } + + } + } + + private UploadResult PerformUploadWithActualFilename( + string url, + Dictionary fields, + string content, + string filename) + { + var formData = new MultipartFormDataContent(); + + foreach (var field in fields) + { + formData.Add(new StringContent(field.Value), field.Key); + } + + // Add file content with the actual filename + var fileContent = new ByteArrayContent(Encoding.UTF8.GetBytes(content)); + fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/plain"); + formData.Add(fileContent, "file", filename); + + using (var httpClient = new System.Net.Http.HttpClient()) + { + var httpResponse = httpClient.PostAsync(url, formData).Result; + + return new UploadResult + { + IsSuccessful = httpResponse.IsSuccessStatusCode, + StatusCode = httpResponse.StatusCode, + ResponseText = httpResponse.Content.ReadAsStringAsync().Result + }; + } + } + + private void TestPresignedPostWithFilenameVariable(PresignedPostTestParameters testParams) + { + var client = new AmazonS3Client(testParams.Region); + try + { + // Create test bucket + testParams.BucketName = S3TestUtils.CreateBucketWithWait(client); + + // Create a presigned POST with key ending in ${filename} + string keyPrefix = "uploads/"; + string objectKey = keyPrefix + "${filename}"; + string actualFilename = "test-file-" + DateTime.UtcNow.Ticks + ".txt"; + + var response = GeneratePresignedPostRequest( + client, + testParams.BucketName, + objectKey, + testParams.Expiration); + + // Verify policy contains starts-with condition + var policyBytes = Convert.FromBase64String(response.Fields["Policy"]); + var policyJson = Encoding.UTF8.GetString(policyBytes); + var policyDoc = JsonDocument.Parse(policyJson); + + bool hasStartsWithCondition = false; + foreach (var condition in policyDoc.RootElement.GetProperty("conditions").EnumerateArray()) + { + if (condition.ValueKind == JsonValueKind.Array && + condition.GetArrayLength() == 3 && + condition[0].GetString() == "starts-with" && + condition[1].GetString() == "$key") + { + string foundPrefix = condition[2].GetString(); + Assert.AreEqual(keyPrefix, foundPrefix, "Policy should contain starts-with condition with correct prefix"); + hasStartsWithCondition = true; + break; + } + } + + Assert.IsTrue(hasStartsWithCondition, "Policy should contain a starts-with condition for the key"); + + // Perform upload with actual filename + string expectedFinalKey = keyPrefix + actualFilename; + var uploadResult = PerformUploadWithActualFilename( + response.Url, + response.Fields, + TestContent, + actualFilename); + + // Verify upload success + Assert.IsTrue(uploadResult.IsSuccessful, $"Upload failed with status {uploadResult.StatusCode}: {uploadResult.ResponseText}"); + + // Verify the object exists with the expected key (prefix + actual filename) + try + { + var objectMetadata = client.GetObjectMetadata(testParams.BucketName, expectedFinalKey); + Assert.IsNotNull(objectMetadata, "Object should exist with the expected key"); + + // Verify content + var getObjectResponse = client.GetObject(testParams.BucketName, expectedFinalKey); + using (var reader = new StreamReader(getObjectResponse.ResponseStream)) + { + var content = reader.ReadToEnd(); + Assert.AreEqual(TestContent, content, "Object content should match the uploaded content"); + } + } + catch (AmazonS3Exception ex) + { + Assert.Fail($"Failed to get object with key '{expectedFinalKey}': {ex.Message}"); + } + } + finally + { + if (testParams.BucketName != null) + AmazonS3Util.DeleteS3BucketWithObjects(client, testParams.BucketName); + } + } + + private void AssertPresignedPostWithConditions(AmazonS3Client client, PresignedPostTestParameters testParams) + { + string objectKey = TestKey + DateTime.UtcNow.Ticks; + + // Generate presigned POST response + var request = new CreatePresignedPostRequest + { + BucketName = testParams.BucketName, + Key = objectKey, + Expires = testParams.Expiration + }; + + // Add custom fields + foreach (var field in testParams.Fields) + { + request.Fields.Add(field.Key, field.Value); + } + + // Add conditions + foreach (var condition in testParams.Conditions) + { + request.Conditions.Add(condition); + } + + var response = client.CreatePresignedPost(request); + + // Use the presigned post form to upload a file that meets the conditions + var validFormData = new MultipartFormDataContent(); + + // Add all form fields + foreach (var field in response.Fields) + { + validFormData.Add(new StringContent(field.Value), field.Key); + } + + // Check if we have a Content-Type starts-with condition + var contentTypeCondition = testParams.Conditions.FirstOrDefault(c => c is StartsWithCondition && + ((StartsWithCondition)c).FieldName == "Content-Type") as StartsWithCondition; + + // Add file content that meets conditions + if (contentTypeCondition != null) + { + // With JavaScript-like behavior, explicitly set Content-Type for uploads with starts-with conditions + var fileContent = new ByteArrayContent(Encoding.UTF8.GetBytes(TestContent)); + + // Use a Content-Type that satisfies the starts-with condition + string contentTypeToUse = "text/plain"; // Default for our tests that use text/ prefix + fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentTypeToUse); + + // Also add Content-Type as a form field if it's not already in response.Fields + if (!response.Fields.ContainsKey("Content-Type")) + { + validFormData.Add(new StringContent(contentTypeToUse), "Content-Type"); + } + + validFormData.Add(fileContent, "file", objectKey); + } + else + { + // Standard file upload without special Content-Type handling + validFormData.Add(new ByteArrayContent(Encoding.UTF8.GetBytes(TestContent)), "file", objectKey); + } + + // Create and configure the HttpClient + using (var httpClient = new System.Net.Http.HttpClient()) + { + // Send the valid POST request + var validResponse = httpClient.PostAsync(response.Url, validFormData).Result; + + // Verify the upload was successful + Assert.IsTrue(validResponse.IsSuccessStatusCode); + + // Verify the uploaded object exists and has the correct content + var getObjectResponse = client.GetObject(testParams.BucketName, objectKey); + using (var reader = new StreamReader(getObjectResponse.ResponseStream)) + { + var content = reader.ReadToEnd(); + Assert.AreEqual(TestContent, content); + } + + // Delete the object for the next test + client.DeleteObject(testParams.BucketName, objectKey); + + // Test a violation of the Content-Type condition if present + var invalidContentTypeCondition = testParams.Conditions.FirstOrDefault(c => c is StartsWithCondition && + ((StartsWithCondition)c).FieldName == "Content-Type") as StartsWithCondition; + + if (invalidContentTypeCondition != null) + { + // Create new form data with invalid Content-Type + var invalidFormData = new MultipartFormDataContent(); + + // Add all fields from response + foreach (var field in response.Fields) + { + invalidFormData.Add(new StringContent(field.Value), field.Key); + } + + // Manually add a Content-Type that violates the condition + // Note: With JavaScript-like behavior, Content-Type might not be in response.Fields + // if there's a starts-with condition for it + invalidFormData.Add(new StringContent("image/jpeg"), "Content-Type"); + + // Add file content + var fileContent = new ByteArrayContent(Encoding.UTF8.GetBytes(TestContent)); + fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/jpeg"); + invalidFormData.Add(fileContent, "file", objectKey); + + // This request should fail with 403 Forbidden + var invalidResponse = httpClient.PostAsync(response.Url, invalidFormData).Result; + Assert.AreEqual(HttpStatusCode.Forbidden, invalidResponse.StatusCode); + } + + // Test a violation of the content length range condition if present + var contentLengthCondition = testParams.Conditions.FirstOrDefault(c => c is ContentLengthRangeCondition) as ContentLengthRangeCondition; + if (contentLengthCondition != null && contentLengthCondition.MaximumLength < 10 * 1024 * 1024) // Only if max is reasonable + { + // Create new form data with a file that exceeds max size + var oversizeFormData = new MultipartFormDataContent(); + + // Add all fields + foreach (var field in response.Fields) + { + oversizeFormData.Add(new StringContent(field.Value), field.Key); + } + + // Generate a file larger than the max size + var largeContent = new byte[contentLengthCondition.MaximumLength + 1024]; // Exceed by 1KB + new Random().NextBytes(largeContent); + + oversizeFormData.Add(new ByteArrayContent(largeContent), "file", objectKey); + + // This request should fail + var oversizeResponse = httpClient.PostAsync(response.Url, oversizeFormData).Result; + Assert.AreEqual(HttpStatusCode.BadRequest, oversizeResponse.StatusCode); + } + } + } + } +} 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..8d0b1a0278fb --- /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.Model; +using Amazon.Util; + +namespace AWSSDK.UnitTests +{ + [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..aa15c1455019 --- /dev/null +++ b/sdk/test/Services/S3/UnitTests/Custom/CreatePresignedPostResponseTests.cs @@ -0,0 +1,361 @@ +/* + * 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.S3.Model; + +namespace AWSSDK.UnitTests +{ + [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..f95ebeee7605 --- /dev/null +++ b/sdk/test/Services/S3/UnitTests/Custom/CreatePresignedPostTests.cs @@ -0,0 +1,994 @@ +/* + * 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.Model; +using Amazon.Util; + +namespace AWSSDK.UnitTests +{ + [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 Deduplication Tests + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_DuplicateFieldAndCondition_ShouldDeduplicateInPolicy() + { + // Arrange - Create a scenario with duplicate field and exact match condition + var request = new CreatePresignedPostRequest + { + BucketName = "test-bucket", + Key = "demo/photo.jpg", + Expires = DateTime.UtcNow.AddHours(1) + }; + + // Add ACL in both Fields and Conditions (should be deduplicated) + request.Fields["acl"] = "public-read"; + request.Conditions.Add(S3PostCondition.ExactMatch("acl", "public-read")); + + // Add Content-Type in both Fields and Conditions (should be deduplicated) + request.Fields["Content-Type"] = "image/jpeg"; + request.Conditions.Add(S3PostCondition.ExactMatch("Content-Type", "image/jpeg")); + + // Add a non-duplicate condition (should remain) + request.Conditions.Add(S3PostCondition.StartsWith("key", "demo/")); + request.Conditions.Add(S3PostCondition.ContentLengthRange(1024, 5242880)); + + // Act + var response = _s3Client.CreatePresignedPost(request); + + // Assert - Verify response contains the fields + Assert.AreEqual("public-read", response.Fields["acl"]); + Assert.AreEqual("image/jpeg", response.Fields["Content-Type"]); + + // Assert - Verify policy document has no duplicates + 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"); + var conditionsList = new List(); + foreach (var condition in conditions.EnumerateArray()) + { + conditionsList.Add(condition); + } + + // Count exact match conditions for ACL + var aclConditions = conditionsList.Count(c => + c.ValueKind == JsonValueKind.Object && + c.TryGetProperty("acl", out var aclProp) && + aclProp.GetString() == "public-read"); + + Assert.AreEqual(1, aclConditions, "Should have exactly one ACL condition (no duplicates)"); + + // Count exact match conditions for Content-Type + var contentTypeConditions = conditionsList.Count(c => + c.ValueKind == JsonValueKind.Object && + c.TryGetProperty("Content-Type", out var ctProp) && + ctProp.GetString() == "image/jpeg"); + + Assert.AreEqual(1, contentTypeConditions, "Should have exactly one Content-Type condition (no duplicates)"); + + // Verify non-duplicate conditions are still present + var startsWithConditions = conditionsList.Count(c => + c.ValueKind == JsonValueKind.Array && + c.GetArrayLength() >= 3 && + c[0].GetString() == "starts-with" && + c[1].GetString() == "$key" && + c[2].GetString() == "demo/"); + + Assert.AreEqual(1, startsWithConditions, "Should have exactly one starts-with condition"); + + var contentLengthConditions = conditionsList.Count(c => + c.ValueKind == JsonValueKind.Array && + c.GetArrayLength() >= 3 && + c[0].GetString() == "content-length-range" && + c[1].GetInt64() == 1024 && + c[2].GetInt64() == 5242880); + + Assert.AreEqual(1, contentLengthConditions, "Should have exactly one content-length-range condition"); + } + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_FieldWithoutMatchingCondition_ShouldIncludeBoth() + { + // Arrange - Create fields that don't match conditions + var request = new CreatePresignedPostRequest + { + BucketName = "test-bucket", + Expires = DateTime.UtcNow.AddHours(1) + }; + + request.Fields["acl"] = "public-read"; + request.Fields["Content-Type"] = "image/jpeg"; + + // Add conditions that don't match the field values + request.Conditions.Add(S3PostCondition.ExactMatch("acl", "private")); // Different value + request.Conditions.Add(S3PostCondition.ExactMatch("x-amz-meta-test", "value")); // Different field + + // 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 conditions = policyDoc.RootElement.GetProperty("conditions"); + var conditionsList = new List(); + foreach (var condition in conditions.EnumerateArray()) + { + conditionsList.Add(condition); + } + + // Should have both ACL conditions since values are different + var publicReadConditions = conditionsList.Count(c => + c.ValueKind == JsonValueKind.Object && + c.TryGetProperty("acl", out var aclProp) && + aclProp.GetString() == "public-read"); + + var privateConditions = conditionsList.Count(c => + c.ValueKind == JsonValueKind.Object && + c.TryGetProperty("acl", out var aclProp) && + aclProp.GetString() == "private"); + + Assert.AreEqual(1, publicReadConditions, "Should have public-read ACL condition from field"); + Assert.AreEqual(1, privateConditions, "Should have private ACL condition from condition"); + + // Should have Content-Type condition from field (no matching condition) + var contentTypeConditions = conditionsList.Count(c => + c.ValueKind == JsonValueKind.Object && + c.TryGetProperty("Content-Type", out var ctProp) && + ctProp.GetString() == "image/jpeg"); + + Assert.AreEqual(1, contentTypeConditions, "Should have Content-Type condition from field"); + + // Should have x-amz-meta-test condition from condition + var metaTestConditions = conditionsList.Count(c => + c.ValueKind == JsonValueKind.Object && + c.TryGetProperty("x-amz-meta-test", out var metaProp) && + metaProp.GetString() == "value"); + + Assert.AreEqual(1, metaTestConditions, "Should have x-amz-meta-test condition from condition"); + } + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_OnlyStartsWith_ShouldNotDeduplicate() + { + // Arrange - Ensure StartsWith conditions are never deduplicated + var request = new CreatePresignedPostRequest + { + BucketName = "test-bucket", + Expires = DateTime.UtcNow.AddHours(1) + }; + + request.Fields["Content-Type"] = "image/jpeg"; + + // Add StartsWith condition - should not be deduplicated even if field exists + request.Conditions.Add(S3PostCondition.StartsWith("Content-Type", "image/")); + + // 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 conditions = policyDoc.RootElement.GetProperty("conditions"); + var conditionsList = new List(); + foreach (var condition in conditions.EnumerateArray()) + { + conditionsList.Add(condition); + } + + // Should have exact match condition from field + var exactMatchConditions = conditionsList.Count(c => + c.ValueKind == JsonValueKind.Object && + c.TryGetProperty("Content-Type", out var ctProp) && + ctProp.GetString() == "image/jpeg"); + + Assert.AreEqual(1, exactMatchConditions, "Should have exact match Content-Type condition from field"); + + // Should have starts-with condition from condition + var startsWithConditions = conditionsList.Count(c => + c.ValueKind == JsonValueKind.Array && + c.GetArrayLength() >= 3 && + c[0].GetString() == "starts-with" && + c[1].GetString() == "$Content-Type" && + c[2].GetString() == "image/"); + + Assert.AreEqual(1, startsWithConditions, "Should have starts-with Content-Type condition from condition"); + } + + #endregion + + #region ${filename} Special Handling Tests + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_KeyWithFilenameVariable_UsesStartsWithCondition() + { + // Arrange + var request = new CreatePresignedPostRequest + { + BucketName = "test-bucket", + Key = "uploads/${filename}", // Key with ${filename} + Expires = DateTime.UtcNow.AddHours(1) + }; + + // Act + var response = _s3Client.CreatePresignedPost(request); + + // Decode and analyze the policy + var policyJson = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(response.Fields["Policy"])); + var policy = JsonDocument.Parse(policyJson); + + // Assert + bool hasStartsWithCondition = false; + + foreach (var condition in policy.RootElement.GetProperty("conditions").EnumerateArray()) + { + // Look for a starts-with condition for the key field + if (condition.ValueKind == JsonValueKind.Array && + condition.GetArrayLength() == 3 && + condition[0].GetString() == "starts-with" && + condition[1].GetString() == "$key") + { + string keyPrefix = condition[2].GetString(); + + // Verify that the key prefix is correct + Assert.AreEqual("uploads/", keyPrefix); + hasStartsWithCondition = true; + break; + } + } + + Assert.IsTrue(hasStartsWithCondition, "Policy should contain a starts-with condition for the key"); + } + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_KeyWithoutFilenameVariable_UsesExactMatchCondition() + { + // Arrange + var request = new CreatePresignedPostRequest + { + BucketName = "test-bucket", + Key = "uploads/regular-file.txt", // Regular key without ${filename} + Expires = DateTime.UtcNow.AddHours(1) + }; + + // Act + var response = _s3Client.CreatePresignedPost(request); + + // Decode and analyze the policy + var policyJson = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(response.Fields["Policy"])); + var policy = JsonDocument.Parse(policyJson); + + // Assert + bool hasExactMatchCondition = false; + + foreach (var condition in policy.RootElement.GetProperty("conditions").EnumerateArray()) + { + // Look for an exact match condition for the key field + if (condition.ValueKind == JsonValueKind.Object && + condition.TryGetProperty("key", out var keyValue)) + { + string key = keyValue.GetString(); + + // Verify that the key is exactly what we specified + Assert.AreEqual("uploads/regular-file.txt", key); + hasExactMatchCondition = true; + break; + } + } + + Assert.IsTrue(hasExactMatchCondition, "Policy should contain an exact match condition for the key"); + } + + [TestMethod] + [TestCategory("S3")] + public void CreatePresignedPost_KeyWithFilenameVariableInMiddle_UsesExactMatchCondition() + { + // Arrange + var request = new CreatePresignedPostRequest + { + BucketName = "test-bucket", + Key = "uploads/${filename}/preview.jpg", // ${filename} not at the end + Expires = DateTime.UtcNow.AddHours(1) + }; + + // Act + var response = _s3Client.CreatePresignedPost(request); + + // Decode and analyze the policy + var policyJson = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(response.Fields["Policy"])); + var policy = JsonDocument.Parse(policyJson); + + // Assert + bool hasExactMatchCondition = false; + + foreach (var condition in policy.RootElement.GetProperty("conditions").EnumerateArray()) + { + // Look for an exact match condition for the key field + if (condition.ValueKind == JsonValueKind.Object && + condition.TryGetProperty("key", out var keyValue)) + { + string key = keyValue.GetString(); + + // Verify that the key is exactly what we specified + Assert.AreEqual("uploads/${filename}/preview.jpg", key); + hasExactMatchCondition = true; + break; + } + } + + Assert.IsTrue(hasExactMatchCondition, "Policy should contain an exact match condition for the key"); + } + + #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..88541ec9c2d3 --- /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.Model; + +namespace AWSSDK.UnitTests +{ + [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 + } +} diff --git a/sdk/test/Services/S3/UnitTests/Custom/S3PostUploadSignedPolicyTests.cs b/sdk/test/Services/S3/UnitTests/Custom/S3PostUploadSignedPolicyTests.cs index cdc419ffd21f..dd18b190f189 100644 --- a/sdk/test/Services/S3/UnitTests/Custom/S3PostUploadSignedPolicyTests.cs +++ b/sdk/test/Services/S3/UnitTests/Custom/S3PostUploadSignedPolicyTests.cs @@ -24,7 +24,7 @@ using Amazon.S3.Util; using Amazon; -namespace AWSSDK.UnitTests.S3.Custom +namespace AWSSDK.UnitTests { [TestClass] public class S3PostUploadSignedPolicyTests