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