Files
versitygw/tests/integration/PostObject.go
niksis02 9f786b3c2c feat: global error refactoring
Fixes #2123
Fixes #2120
Fixes #2116
Fixes #2111
Fixes #2108
Fixes #2086
Fixes #2085
Fixes #2083
Fixes #2081
Fixes #2080
Fixes #2073
Fixes #2072
Fixes #2071
Fixes #2069
Fixes #2044
Fixes #2043
Fixes #2042
Fixes #2041
Fixes #2040
Fixes #2039
Fixes #2036
Fixes #2035
Fixes #2034
Fixes #2028
Fixes #2020
Fixes #1842
Fixes #1810
Fixes #1780
Fixes #1775
Fixes #1736
Fixes #1705
Fixes #1663
Fixes #1645
Fixes #1583
Fixes #1526
Fixes #1514
Fixes #1493
Fixes #1487
Fixes #959
Fixes #779
Closes #823
Closes #85

Refactor global S3 error handling around structured error types and centralized XML response generation.

All S3 errors now share the common APIError base for the fields every error has: Code, HTTP status code, and Message. Non-traditional errors that need AWS-compatible XML fields now have dedicated typed errors in the s3err package. Each typed error implements the shared S3Error behavior so controllers and middleware can handle errors consistently while still emitting error-specific XML fields.

Add a dedicated InvalidArgumentError type because InvalidArgument is used widely across request validation, auth, copy source handling, object lock validation, multipart validation, and header parsing. The new InvalidArgument path uses explicit InvalidArgErrorCode constants with predefined descriptions and ArgumentName values, keeping call sites readable while preserving the correct InvalidArgument XML shape and optional ArgumentValue.

New structured errors added in s3err:
- `AccessForbiddenError`: Method, ResourceType
- `BadDigestError`: CalculatedDigest, ExpectedDigest
- `BucketError`: BucketName
- `ContentSHA256MismatchError`: ClientComputedContentSHA256, S3ComputedContentSHA256
- `EntityTooLargeError`: ProposedSize, MaxSizeAllowed
- `EntityTooSmallError`: ProposedSize, MinSizeAllowed
- `ExpiredPresignedURLError`: ServerTime, XAmzExpires, Expires
- `InvalidAccessKeyIdError`: AWSAccessKeyId
- `InvalidArgumentError`: Description, ArgumentName, ArgumentValue
- `InvalidChunkSizeError`: Chunk, BadChunkSize
- `InvalidDigestError`: ContentMD5
- `InvalidLocationConstraintError`: LocationConstraint
- `InvalidPartError`: UploadId, PartNumber, ETag
- `InvalidRangeError`: RangeRequested, ActualObjectSize
- `InvalidTagError`: TagKey, TagValue
- `KeyTooLongError`: Size, MaxSizeAllowed
- `MetadataTooLargeError`: Size, MaxSizeAllowed
- `MethodNotAllowedError`: Method, ResourceType, AllowedMethods
- `NoSuchUploadError`: UploadId
- `NoSuchVersionError`: Key, VersionId
- `NotImplementedError`: Header, AdditionalMessage
- `PreconditionFailedError`: Condition
- `RequestTimeTooSkewedError`: RequestTime, ServerTime, MaxAllowedSkewMilliseconds
- `SignatureDoesNotMatchError`: AWSAccessKeyId, StringToSign, SignatureProvided, StringToSignBytes, CanonicalRequest, CanonicalRequestBytes

Fix CompleteMultipartUpload validation in the Azure backend so missing or empty `ETag` values return the appropriate S3 error instead of allowing a gateway panic.

Fix presigned authentication expiration validation to compare server time in `UTC`, matching the `UTC` timestamp used by presigned URL signing.

Add request ID and host ID support across S3 requests. Each request now receives AWS S3-like identifiers, returned in response headers as `x-amz-request-id` and `x-amz-id-2` and included in all XML error responses as RequestId and HostId. The generated ID structure is designed to resemble AWS S3 request IDs and host IDs.

The request signature calculation/validation for streaming uploads was previously delayed until the request body was fully read, both for Authorization header authentication and presigned URLs.
Now, the signature is validated immediately in the authorization middlewares without reading the request body, since the signature calculation itself does not depend on the request body. Instead, only the `x-amz-content-sha256` SHA-256 hash calculation is delayed.
2026-05-21 23:49:34 +04:00

1217 lines
38 KiB
Go

// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package integration
import (
"bytes"
"context"
"encoding/xml"
"fmt"
"io"
"mime/multipart"
"net/http"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/versity/versitygw/s3err"
"github.com/versity/versitygw/s3response"
)
func PostObject_invalid_content_type(s *S3Conf) error {
testName := "PostObject_invalid_content_type"
return actionHandlerNoSetup(s, testName, func(s3client *s3.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.endpoint+"/"+bucket, strings.NewReader("body"))
if err != nil {
cancel()
return err
}
req.Header.Set("Content-Type", "application/json")
req.ContentLength = 4
resp, err := s.httpClient.Do(req)
cancel()
if err != nil {
return err
}
return checkHTTPResponseApiErr(resp, s3err.GetPreconditionFailedErr(s3err.ConditionPostBucket))
})
}
func PostObject_missing_boundary(s *S3Conf) error {
testName := "PostObject_missing_boundary"
return actionHandlerNoSetup(s, testName, func(s3client *s3.Client, bucket string) error {
body := []byte("irrelevant body")
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.endpoint+"/"+bucket, bytes.NewReader(body))
if err != nil {
cancel()
return err
}
// multipart/form-data without boundary parameter
req.Header.Set("Content-Type", "multipart/form-data")
req.ContentLength = int64(len(body))
resp, err := s.httpClient.Do(req)
cancel()
if err != nil {
return err
}
return checkHTTPResponseApiErr(resp, s3err.GetAPIError(s3err.ErrMalformedPOSTRequest))
})
}
func PostObject_partial_auth_fields(s *S3Conf) error {
testName := "PostObject_partial_auth_fields"
return actionHandlerNoSetup(s, testName, func(s3client *s3.Client, bucket string) error {
for i, field := range []string{
"policy", "x-amz-signature",
"x-amz-credential", "x-amz-date",
"x-amz-algorithm",
} {
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "test-object",
s3Conf: s,
fileContent: []byte("data"),
extraFields: map[string]string{
field: "",
},
})
if err != nil {
return fmt.Errorf("test %v failed: %w", i+1, err)
}
if err := checkHTTPResponseApiErr(resp, s3err.PostAuth.MissingField(field)); err != nil {
return fmt.Errorf("test %v failed: %w", i+1, err)
}
}
return nil
})
}
func PostObject_invalid_algorithm(s *S3Conf) error {
testName := "PostObject_invalid_algorithm"
return actionHandlerNoSetup(s, testName, func(s3client *s3.Client, bucket string) error {
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "test-object",
s3Conf: s,
fileContent: []byte("data"),
extraFields: map[string]string{
"x-amz-algorithm": "invalid",
},
})
if err != nil {
return err
}
return checkHTTPResponseApiErr(resp, s3err.GetInvalidArgumentErr(s3err.InvalidArgOnlyAws4HmacSha256, "invalid"))
})
}
func PostObject_invalid_date(s *S3Conf) error {
testName := "PostObject_invalid_date"
return actionHandlerNoSetup(s, testName, func(s3client *s3.Client, bucket string) error {
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "test-object",
s3Conf: s,
fileContent: []byte("data"),
extraFields: map[string]string{
"x-amz-date": "invalid_date",
},
})
if err != nil {
return err
}
return checkHTTPResponseApiErr(resp, s3err.GetInvalidArgumentErr(s3err.InvalidArgDateHeader, "invalid_date"))
})
}
func PostObject_invalid_credential_format(s *S3Conf) error {
testName := "PostObject_invalid_credential_format"
return actionHandlerNoSetup(s, testName, func(s3client *s3.Client, bucket string) error {
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "test-object",
s3Conf: s,
fileContent: []byte("data"),
extraFields: map[string]string{
"x-amz-credential": "malformed-no-slashes",
},
})
if err != nil {
return err
}
return checkHTTPResponseApiErr(resp, s3err.PostAuth.MalformedCredential("malformed-no-slashes"))
})
}
func PostObject_incorrect_region(s *S3Conf) error {
testName := "PostObject_incorrect_region"
return actionHandlerNoSetup(s, testName, func(s3client *s3.Client, bucket string) error {
wrongRegion := "us-west-2"
if s.awsRegion == wrongRegion {
wrongRegion = "eu-west-1"
}
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "test-object",
s3Conf: s,
region: wrongRegion,
fileContent: []byte("data"),
})
if err != nil {
return err
}
expectedCreds := fmt.Sprintf("%s/%s/%s/s3/aws4_request", s.awsID, time.Now().UTC().Format("20060102"), wrongRegion)
return checkHTTPResponseApiErr(resp, s3err.PostAuth.IncorrectRegion(expectedCreds, s.awsRegion, wrongRegion))
})
}
func PostObject_non_existing_access_key(s *S3Conf) error {
testName := "PostObject_non_existing_access_key"
return actionHandlerNoSetup(s, testName, func(s3client *s3.Client, bucket string) error {
accessKeyID := "this_access_key_id_can_not_really_exist"
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "test-object",
s3Conf: s,
access: accessKeyID,
secret: "a_very_secure_secret_access_key",
fileContent: []byte("data"),
})
if err != nil {
return err
}
return checkHTTPResponseApiErr(resp, s3err.GetInvalidAccessKeyIdErr(accessKeyID))
})
}
func PostObject_signature_mismatch(s *S3Conf) error {
testName := "PostObject_signature_mismatch"
return actionHandlerNoSetup(s, testName, func(s3client *s3.Client, bucket string) error {
const signature = "incorrect_signature"
req, fields, err := newPostObjectRequest(PostRequestConfig{
bucket: bucket,
key: "test-object",
s3Conf: s,
fileContent: []byte("data"),
extraFields: map[string]string{
"x-amz-signature": signature,
},
})
if err != nil {
return err
}
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
resp.Body.Close()
var errResp APIErrorResponse
err = xml.Unmarshal(body, &errResp)
if err != nil {
return err
}
expected := s3err.GetSignatureDoesNotMatchErr(s.awsID, fields["policy"], signature, hexBytes(fields["policy"]), "", "")
if resp.StatusCode != expected.HTTPStatusCode {
return fmt.Errorf("expected response status code to be %v, instead got %v", expected.HTTPStatusCode, resp.StatusCode)
}
return compareS3ApiErrFields(
compareErrField("AWSAccessKeyId", expected.AWSAccessKeyId, errResp.AWSAccessKeyId),
compareErrField("StringToSign", expected.StringToSign, errResp.StringToSign),
compareErrField("SignatureProvided", expected.SignatureProvided, errResp.SignatureProvided),
compareErrField("StringToSignBytes", expected.StringToSignBytes, errResp.StringToSignBytes),
checkErrFieldEmptiness("CanonicalRequest", expected.CanonicalRequest, false),
checkErrFieldEmptiness("CanonicalRequestBytes", expected.CanonicalRequestBytes, false),
)
})
}
func PostObject_expired_due_to_date(s *S3Conf) error {
testName := "PostObject_expired_due_to_date"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
// any x-amz-date older than 1 hour are considered as invalid
// and an expired policy is returned
expiredDate := time.Now().UTC().Add(-1 * time.Hour).Add(-1 * time.Minute)
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "test-object",
s3Conf: s,
fileContent: []byte("data"),
date: expiredDate,
})
if err != nil {
return err
}
return checkHTTPResponseApiErr(resp, s3err.InvalidPolicyDocument.PolicyExpired())
})
}
func PostObject_access_denied(s *S3Conf) error {
testName := "PostObject_access_denied"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
// Anonymous request: suppress all five auth fields so the middleware
// treats this as an unauthenticated POST to a private bucket.
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "test-object",
s3Conf: s,
fileContent: []byte("data"),
extraFields: map[string]string{
"x-amz-algorithm": "",
"x-amz-credential": "",
"x-amz-date": "",
"policy": "",
"x-amz-signature": "",
},
})
if err != nil {
return err
}
return checkHTTPResponseApiErr(resp, s3err.GetAPIError(s3err.ErrAccessDenied))
})
}
func PostObject_policy_access_control(s *S3Conf) error {
testName := "PostObject_policy_access_control"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
for i, test := range []struct {
conditions []any
extraFields map[string]string
expectedErr error
}{
// success: eq condition on content-type matches submitted value
{
conditions: []any{
[]any{"eq", "$content-type", "text/plain"},
},
extraFields: map[string]string{
"content-type": "text/plain",
},
},
// success: starts-with condition on a custom meta field matches submitted value
{
conditions: []any{
[]any{"starts-with", "$x-amz-meta-env", "prod"},
},
extraFields: map[string]string{
"x-amz-meta-env": "production",
},
},
// success: starts-with with an empty prefix acts as a wildcard — any value is accepted
{
conditions: []any{
[]any{"starts-with", "$x-amz-meta-tag", ""},
},
extraFields: map[string]string{
"x-amz-meta-tag": "anything-goes",
},
},
// success: object-form (map) condition matches submitted content-type
{
conditions: []any{
map[string]any{"content-type": "image/png"},
},
extraFields: map[string]string{
"content-type": "image/png",
},
},
// success: eq condition on $bucket matches the actual bucket in the request
{
conditions: []any{
[]any{"eq", "$bucket", bucket},
},
},
// success: x-ignore-* prefixed fields are exempt from policy coverage
{
extraFields: map[string]string{
"x-ignore-custom": "value",
},
},
// success: content-length-range — body size is within the allowed bounds
{
conditions: []any{
[]any{"content-length-range", 1, 100},
},
},
// success: starts-with on content-type where all comma-separated parts satisfy the prefix
{
conditions: []any{
[]any{"starts-with", "$content-type", "text/"},
},
extraFields: map[string]string{
"content-type": "text/plain, text/html",
},
},
// condition failed: eq on content-type — submitted value doesn't match policy
{
conditions: []any{
[]any{"eq", "$content-type", "text/plain"},
},
extraFields: map[string]string{
"content-type": "image/jpeg",
},
expectedErr: s3err.InvalidPolicyDocument.ConditionFailed(`["eq","$content-type","text/plain"]`),
},
// condition failed: starts-with on a meta field — value doesn't begin with the required prefix
{
conditions: []any{
[]any{"starts-with", "$x-amz-meta-path", "allowed/"},
},
extraFields: map[string]string{
"x-amz-meta-path": "forbidden/value",
},
expectedErr: s3err.InvalidPolicyDocument.ConditionFailed(`["starts-with","$x-amz-meta-path","allowed/"]`),
},
// condition failed: eq on $bucket — policy expects a different bucket name
{
conditions: []any{
[]any{"eq", "$bucket", "wrong-bucket-name"},
},
expectedErr: s3err.InvalidPolicyDocument.ConditionFailed(`["eq","$bucket","wrong-bucket-name"]`),
},
// condition failed: object-form condition — submitted content-type doesn't match the policy value
{
conditions: []any{
map[string]any{"content-type": "application/xml"},
},
extraFields: map[string]string{
"content-type": "text/html",
},
expectedErr: s3err.InvalidPolicyDocument.ConditionFailed(`["eq", "$content-type", "application/xml"]`),
},
// condition failed: starts-with on content-type — comma-separated value contains a non-matching part
{
conditions: []any{
[]any{"starts-with", "$content-type", "text/"},
},
extraFields: map[string]string{
"content-type": "text/plain, image/jpeg",
},
expectedErr: s3err.InvalidPolicyDocument.ConditionFailed(`["starts-with","$content-type","text/"]`),
},
// extra input field: x-amz-meta field submitted without a matching policy condition
{
extraFields: map[string]string{
"x-amz-meta-custom": "value",
},
expectedErr: s3err.InvalidPolicyDocument.ExtraInputField("x-amz-meta-custom"),
},
// extra input field: content-type submitted without a matching policy condition
{
extraFields: map[string]string{
"content-type": "text/plain",
},
expectedErr: s3err.InvalidPolicyDocument.ExtraInputField("content-type"),
},
} {
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "test-object",
s3Conf: s,
fileContent: []byte("data"),
extraFields: test.extraFields,
policyConditions: test.conditions,
})
if err != nil {
return fmt.Errorf("test %v failed: %w", i+1, err)
}
if test.expectedErr != nil {
if err := checkHTTPResponseApiErr(resp, test.expectedErr.(s3err.APIError)); err != nil {
return fmt.Errorf("test %v failed: %w", i+1, err)
}
}
if test.expectedErr == nil && resp.StatusCode >= 400 {
return fmt.Errorf("test %v failed: expected a successful response, instead got %d response status", i+1, resp.StatusCode)
}
}
return nil
})
}
func PostObject_policy_expired(s *S3Conf) error {
testName := "PostObject_policy_expired"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
expiredAt := time.Now().UTC().Add(-5 * time.Minute)
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "test-object",
s3Conf: s,
fileContent: []byte("data"),
policyExpiration: expiredAt,
})
if err != nil {
return err
}
return checkHTTPResponseApiErr(resp, s3err.InvalidPolicyDocument.PolicyExpired())
})
}
func PostObject_invalid_policy_document(s *S3Conf) error {
testName := "PostObject_invalid_policy_document"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
validExp := time.Now().AddDate(0, 0, 1)
for i, test := range []struct {
policy *string
expiration time.Time
conditions []any
err s3err.S3Error
}{
// empty policy document
{getPtr(""), time.Time{}, []any{}, s3err.InvalidPolicyDocument.EmptyPolicy()},
// invalid base64
{getPtr("invalid_base64"), time.Time{}, []any{}, s3err.InvalidPolicyDocument.InvalidBase64Encoding()},
// invalid json
{getPtr("aW52YWxpZCBqc29u"), time.Time{}, []any{}, s3err.InvalidPolicyDocument.InvalidJSON()},
// missing expiration
{getPtr("ewogICAgImNvbmRpdGlvbnMiOiBbXQp9"), time.Time{}, []any{}, s3err.InvalidPolicyDocument.MissingExpiration()},
// invalid expiration
{getPtr("ewogICAgImV4cGlyYXRpb24iOiB0cnVlLAogICAgImNvbmRpdGlvbnMiOiBbeyJoZWxsbyI6IndvcmxkIn1dCn0="), time.Time{}, []any{}, s3err.InvalidPolicyDocument.InvalidJSON()},
// invalid expiration date string
{getPtr("ewogICAgImV4cGlyYXRpb24iOiAiaW52YWxpZCIsCiAgICAiY29uZGl0aW9ucyI6IFt7ImhlbGxvIjoid29ybGQifV0KfQ=="), time.Time{}, []any{}, s3err.InvalidPolicyDocument.InvalidExpiration("invalid")},
// missing conditions
{getPtr("ewogICAgImV4cGlyYXRpb24iOiAiMjE0NC0xMS0wOFQwNDoxOTozM1oiCn0="), time.Time{}, []any{}, s3err.InvalidPolicyDocument.MissingConditions()},
// invalid 'conditions'
{getPtr("ewogICAgImV4cGlyYXRpb24iOiAiMjE0NC0xMS0wOFQwNDoxOTozM1oiLAogICAgImNvbmRpdGlvbnMiOiB0cnVlCn0="), time.Time{}, []any{}, s3err.InvalidPolicyDocument.InvalidConditions()},
// invalid condition
{getPtr("ewogICAgImV4cGlyYXRpb24iOiAiMjE0NC0xMS0wOFQwNDoxOTozM1oiLAogICAgImNvbmRpdGlvbnMiOiBbdHJ1ZV0KfQ=="), time.Time{}, []any{}, s3err.InvalidPolicyDocument.InvalidCondition()},
// extra field in policy document
{getPtr("ewogICJjb25kaXRpb25zIjogW3sieC1hbXotZGF0ZSI6ICIyMDI2MDMyN1QwOTE4MjJaIn1dLAogICJleHBpcmF0aW9uIjogIjIwMjYtMDMtMjhUMDk6MTg6MjJaIiwKICAiZXh0cmEiOiAiZmllbGQiCn0="), time.Time{}, []any{}, s3err.InvalidPolicyDocument.UnexpectedField("extra")},
// expired policy
{nil, time.Now().AddDate(0, 0, -1), []any{}, s3err.InvalidPolicyDocument.PolicyExpired()},
// missing condition operation(eq, starts-with ...) identifier
{nil, validExp, []any{[]any{}}, s3err.InvalidPolicyDocument.MissingConditionOperationIdentifier()},
// unknown/invalid condition operator
{nil, validExp, []any{[]any{"invalid", "$content-type", "application/json"}}, s3err.InvalidPolicyDocument.UnknownConditionOperation("invalid")},
// incorrect number of argument in a condition
{nil, validExp, []any{[]any{"eq", "$content-type", "application/json", "something"}}, s3err.InvalidPolicyDocument.IncorrectConditionArgumentsNumber("eq")},
// invalid field argument
{nil, validExp, []any{[]any{"eq", false, "application/json"}}, s3err.InvalidPolicyDocument.InvalidJSON()},
// invalid value argument
{nil, validExp, []any{[]any{"eq", "$content-type", true}}, s3err.InvalidPolicyDocument.InvalidJSON()},
// no $ sign in field
{nil, validExp, []any{[]any{"eq", "content-type", "binary/octet-stream"}}, s3err.InvalidPolicyDocument.ConditionFailed(`["eq","content-type","binary/octet-stream"]`)},
// invalid content-length-range
{nil, validExp, []any{[]any{"content-length-range", 12, false}}, s3err.InvalidPolicyDocument.InvalidJSON()},
// invalid content-length-range 2
{nil, validExp, []any{[]any{"content-length-range", "invalid", "14"}}, s3err.InvalidPolicyDocument.InvalidJSON()},
// multiple property simple condition
{nil, validExp, []any{map[string]any{"expires": "exp", "cache": "smth"}}, s3err.InvalidPolicyDocument.OnePropSimpleCondition()},
// invalid simple condition value
{nil, validExp, []any{map[string]any{"expires": true}}, s3err.InvalidPolicyDocument.InvalidSimpleCondition()},
} {
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "test-object",
s3Conf: s,
fileContent: []byte("data"),
rawPolicy: test.policy,
policyExpiration: test.expiration,
policyConditions: test.conditions,
})
if err != nil {
return fmt.Errorf("test %d failed: %w", i+1, err)
}
if err := checkHTTPResponseApiErr(resp, test.err); err != nil {
return fmt.Errorf("test %d failed: %w", i+1, err)
}
}
return nil
})
}
func PostObject_policy_condition_key_mismatch(s *S3Conf) error {
testName := "PostObject_policy_condition_key_mismatch"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "expected-key",
s3Conf: s,
fileContent: []byte("data"),
policyConditions: []any{
[]any{"eq", "$key", "expected-key"},
},
omitPolicyConditions: map[string]struct{}{
"key": {},
},
extraFields: map[string]string{
"key": "other-key",
},
})
if err != nil {
return err
}
return checkHTTPResponseApiErr(resp, s3err.InvalidPolicyDocument.ConditionFailed(`["eq","$key","expected-key"]`))
})
}
func PostObject_policy_extra_field(s *S3Conf) error {
testName := "PostObject_policy_extra_field"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "test-object",
s3Conf: s,
fileContent: []byte("data"),
extraFields: map[string]string{
"content-type": "text/plain",
},
})
if err != nil {
return err
}
return checkHTTPResponseApiErr(resp, s3err.InvalidPolicyDocument.ExtraInputField("content-type"))
})
}
func PostObject_policy_missing_bucket_condition(s *S3Conf) error {
testName := "PostObject_policy_missing_bucket_condition"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "test-object",
s3Conf: s,
fileContent: []byte("data"),
omitPolicyConditions: map[string]struct{}{
"bucket": {},
},
})
if err != nil {
return err
}
return checkHTTPResponseApiErr(resp, s3err.InvalidPolicyDocument.ExtraInputField("bucket"))
})
}
func PostObject_policy_content_length_too_large(s *S3Conf) error {
testName := "PostObject_policy_content_length_too_large"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
// Allow at most 5 bytes; we upload 10 bytes.
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "test-object",
s3Conf: s,
fileContent: []byte("0123456789"),
policyConditions: []any{
[]any{"content-length-range", 0, 5},
},
})
if err != nil {
return err
}
return checkHTTPResponseApiErr(resp, s3err.GetEntityTooLargeErr(10, 5))
})
}
func PostObject_policy_content_length_too_small(s *S3Conf) error {
testName := "PostObject_policy_content_length_too_small"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
// Require at least 100 bytes; we upload 2 bytes.
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "test-object",
s3Conf: s,
fileContent: []byte("hi"),
policyConditions: []any{
[]any{"content-length-range", 100, 1024},
},
})
if err != nil {
return err
}
return checkHTTPResponseApiErr(resp, s3err.GetEntityTooSmallErr(2, 100))
})
}
func PostObject_success(s *S3Conf) error {
testName := "PostObject_success"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "my-object",
s3Conf: s,
fileContent: []byte("some dummy data"),
})
if err != nil {
return err
}
if resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("expected status 204, instead got %d", resp.StatusCode)
}
if resp.Header.Get("ETag") == "" {
return fmt.Errorf("expected ETag response header to be set")
}
location := constructObjectLocation(s.endpoint, bucket, "my-object", s.hostStyle)
if resp.Header.Get("Location") != location {
return fmt.Errorf("expected Location to be %s, instead got %s", location, resp.Header.Get("Location"))
}
return nil
})
}
func PostObject_success_status_200(s *S3Conf) error {
testName := "PostObject_success_status_200"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "test-object",
s3Conf: s,
fileContent: []byte("hello"),
policyConditions: []any{
[]any{"eq", "$success_action_status", "200"},
},
extraFields: map[string]string{
"success_action_status": "200",
},
})
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("expected status 200, got %d", resp.StatusCode)
}
return nil
})
}
func PostObject_success_status_201(s *S3Conf) error {
testName := "PostObject_success_status_201"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
key := "test-object"
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: key,
s3Conf: s,
fileContent: []byte("hello"),
policyConditions: []any{
[]any{"eq", "$success_action_status", "201"},
},
extraFields: map[string]string{
"success_action_status": "201",
},
})
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return fmt.Errorf("expected status 201, got %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
var postResp s3response.PostResponse
if err := xml.Unmarshal(body, &postResp); err != nil {
return fmt.Errorf("failed to unmarshal PostResponse XML: %w", err)
}
if postResp.Bucket != bucket {
return fmt.Errorf("expected Bucket to be %q, instead got %q", bucket, postResp.Bucket)
}
if postResp.Key != key {
return fmt.Errorf("expected Key to be %q ,instead got %q", key, postResp.Key)
}
if postResp.ETag == "" {
return fmt.Errorf("expected non-empty ETag in response")
}
location := constructObjectLocation(s.endpoint, bucket, key, s.hostStyle)
if resp.Header.Get("Location") != location {
return fmt.Errorf("expected Location to be %s, instead got %s", location, resp.Header.Get("Location"))
}
return nil
})
}
func PostObject_should_ignore_anything_after_file(s *S3Conf) error {
testName := "PostObject_should_ignore_anything_after_file"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
key := "test-object"
signingDate := time.Now().UTC()
fields := buildSignedPostFields(bucket, key, s.awsID, s.awsRegion, signingDate)
policy, err := encodePostPolicy([]any{}, time.Now().UTC().Add(time.Minute*10), fields, make(map[string]struct{}))
if err != nil {
return err
}
fields["policy"] = policy
fields["x-amz-signature"] = signPostPolicy(policy, signingDate.Format("20060102"), s.awsRegion, s.awsSecret)
objData := []byte("dummy data")
body, boundary, err := buildPostObjectBody(fields, map[string]string{}, objData)
if err != nil {
return err
}
body = append(body, []byte("tail data that should be ignored")...)
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/%s", s.endpoint, bucket), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", fmt.Sprintf("multipart/form-data; boundary=%s", boundary))
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
if resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("expected status code to be 204, instead got %d", resp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
res, err := s3client.GetObject(ctx, &s3.GetObjectInput{
Bucket: &bucket,
Key: &key,
})
cancel()
if err != nil {
return err
}
defer res.Body.Close()
gotObjData, err := io.ReadAll(res.Body)
if err != nil {
return err
}
if !bytes.Equal(gotObjData, objData) {
return fmt.Errorf("expected the object data to be %s, instead got %s", objData, gotObjData)
}
return nil
})
}
func PostObject_success_with_meta_properties(s *S3Conf) error {
testName := "PostObject_success_with_meta_properties"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
key := "test-object"
cType := "image/png"
cacheControl := "max-age=100"
expires := "Fri, 21 Mar 2026 00:00:00 GMT"
cLanguage := "en-US"
cDisposition := "inline"
cEncoding := "gzip"
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: key,
s3Conf: s,
fileContent: []byte("dummy data"),
policyConditions: []any{
[]any{"eq", "$Content-Type", cType},
[]any{"eq", "$Content-Disposition", cDisposition},
[]any{"eq", "$Content-Language", cLanguage},
[]any{"eq", "$Content-Encoding", cEncoding},
[]any{"eq", "$Cache-Control", cacheControl},
[]any{"eq", "$Expires", expires},
[]any{"eq", "$x-amz-meta-foo", "bar"},
[]any{"eq", "$x-amz-meta-baz", "quxx"},
},
extraFields: map[string]string{
"Content-Type": cType,
"Cache-Control": cacheControl,
"Expires": expires,
"Content-Language": cLanguage,
"Content-Disposition": cDisposition,
"Content-Encoding": cEncoding,
"x-amz-meta-foo": "bar",
"x-amz-meta-baz": "quxx",
},
})
if err != nil {
return err
}
if resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("expected status code to be 204, instead got %d", resp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
out, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: &bucket,
Key: &key,
})
cancel()
if err != nil {
return err
}
if getString(out.ContentType) != cType {
return fmt.Errorf("expected Content-Type %s, instead got %s",
cType, getString(out.ContentType))
}
if getString(out.ContentDisposition) != cDisposition {
return fmt.Errorf("expected Content-Disposition %s, instead got %s",
cDisposition, getString(out.ContentDisposition))
}
if getString(out.ContentEncoding) != cEncoding {
return fmt.Errorf("expected Content-Encoding %s, instead got %s",
cEncoding, getString(out.ContentEncoding))
}
if getString(out.ContentLanguage) != cLanguage {
return fmt.Errorf("expected Content-Language %s, instead got %s",
cLanguage, getString(out.ContentLanguage))
}
if getString(out.ExpiresString) != expires {
return fmt.Errorf("expected Expires %s, instead got %s",
expires, getString(out.ExpiresString))
}
if getString(out.CacheControl) != cacheControl {
return fmt.Errorf("expected Cache-Control %s, instead got %s",
cacheControl, getString(out.CacheControl))
}
expectedMeta := map[string]string{
"foo": "bar",
"baz": "quxx",
}
if !areMapsSame(expectedMeta, out.Metadata) {
return fmt.Errorf("expected the object metadata to be %v, instead got %v", expectedMeta, out.Metadata)
}
return nil
})
}
func PostObject_invalid_tagging(s *S3Conf) error {
testName := "PostObject_invalid_tagging"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
tagging := "invalid"
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "test-object",
s3Conf: s,
fileContent: []byte("data"),
policyConditions: []any{
[]any{"eq", "$tagging", tagging},
},
extraFields: map[string]string{
"tagging": tagging,
},
})
if err != nil {
return err
}
return checkHTTPResponseApiErr(resp, s3err.GetAPIError(s3err.ErrMalformedXML))
})
}
func PostObject_success_with_tagging(s *S3Conf) error {
testName := "PostObject_success_with_tagging"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
key := "test-object"
taggingXML := `<Tagging><TagSet><Tag><Key>env</Key><Value>test</Value></Tag></TagSet></Tagging>`
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: key,
s3Conf: s,
fileContent: []byte("data"),
policyConditions: []any{
[]any{"eq", "$tagging", taggingXML},
},
extraFields: map[string]string{
"tagging": taggingXML,
},
})
if err != nil {
return err
}
if resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("expected status 204, got %d", resp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
tagging, err := s3client.GetObjectTagging(ctx, &s3.GetObjectTaggingInput{
Bucket: &bucket,
Key: &key,
})
cancel()
if err != nil {
return err
}
expectedTagging := []types.Tag{{Key: getPtr("env"), Value: getPtr("test")}}
if !areTagsSame(expectedTagging, tagging.TagSet) {
return fmt.Errorf("expected %v tagging, instead got %v", expectedTagging, tagging.TagSet)
}
return nil
})
}
func PostObject_invalid_checksum_value(s *S3Conf) error {
testName := "PostObject_invalid_checksum_value"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
for _, algo := range types.ChecksumAlgorithmCrc32.Values() {
algoHdr := fmt.Sprintf("x-amz-checksum-%s", strings.ToLower(string(algo)))
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "test-object",
s3Conf: s,
fileContent: []byte("data"),
policyConditions: []any{
map[string]string{
algoHdr: "invalid",
},
},
extraFields: map[string]string{
algoHdr: "invalid",
},
})
if err != nil {
return err
}
if err := checkHTTPResponseApiErr(resp, s3err.GetInvalidChecksumHeaderErr(algoHdr)); err != nil {
return err
}
}
return nil
})
}
func PostObject_invalid_checksum_algorithm(s *S3Conf) error {
testName := "PostObject_invalid_checksum_algorithm"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
invalidAlgoHdr := "x-amz-checksum-invalid"
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "test-object",
s3Conf: s,
fileContent: []byte("data"),
policyConditions: []any{
map[string]string{
invalidAlgoHdr: "invalid",
},
},
extraFields: map[string]string{
invalidAlgoHdr: "invalid",
},
})
if err != nil {
return err
}
return checkHTTPResponseApiErr(resp, s3err.GetAPIError(s3err.ErrInvalidChecksumHeader))
})
}
func PostObject_multiple_checksum_headers(s *S3Conf) error {
testName := "PostObject_multiple_checksum_headers"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "test-object",
s3Conf: s,
fileContent: []byte("test data"),
policyConditions: []any{
map[string]string{
"x-amz-checksum-crc32": "0wiusg==",
},
map[string]string{
"x-amz-checksum-crc32c": "M3m0yg==",
},
},
extraFields: map[string]string{
"x-amz-checksum-crc32": "0wiusg==",
"x-amz-checksum-crc32c": "M3m0yg==",
},
})
if err != nil {
return err
}
return checkHTTPResponseApiErr(resp, s3err.GetAPIError(s3err.ErrMultipleChecksumHeaders))
})
}
func PostObject_checksums_success(s *S3Conf) error {
testName := "PostObject_checksums_success"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
for i, test := range []struct {
algo string
checksum string
}{
{"x-amz-checksum-crc32", "0wiusg=="},
{"x-amz-checksum-crc32c", "M3m0yg=="},
{"x-amz-checksum-crc64nvme", "rsrzr5yYqFU="},
{"x-amz-checksum-sha1", "9I3YU4IIYIFsddVND1hNyGMyenw="},
{"x-amz-checksum-sha256", "kW8AJ6V1B0znKjMXd8NHjWUT94alkb2JLaGld78jNfk="},
{"x-amz-checksum-sha512", "Dh4h7PEF7IU9JNcohnrXBhPCFmOkaTB0sqNhnBvTnWa1iMM3I7tGbHJCToDjymPCSQeKs0e6uUKFAOfuQwWdDQ=="},
{"x-amz-checksum-md5", "63M6AMDJ0zbmVpGjerVCkw=="},
{"x-amz-checksum-xxhash64", "+lb36/ER8bo="},
{"x-amz-checksum-xxhash3", "jw+pSh/pbMQ="},
{"x-amz-checksum-xxhash128", "8BLDqqIWji+ITOsp/JjN/Q=="},
} {
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "test-object",
s3Conf: s,
fileContent: []byte("test data"),
policyConditions: []any{
map[string]string{
test.algo: test.checksum,
},
},
extraFields: map[string]string{
test.algo: test.checksum,
},
})
if err != nil {
return err
}
if resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("test %d failed: expected the response status code to be 204, instead got %d", i+1, resp.StatusCode)
}
}
return nil
})
}
func PostObject_success_double_dash_boundary(s *S3Conf) error {
testName := "PostObject_success_double_dash_boundary"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
key := "test-object"
objData := []byte("hello world")
signingDate := time.Now().UTC()
fields := buildSignedPostFields(bucket, key, s.awsID, s.awsRegion, signingDate)
policy, err := encodePostPolicy([]any{}, time.Now().UTC().Add(10*time.Minute), fields, make(map[string]struct{}))
if err != nil {
return err
}
fields["policy"] = policy
fields["x-amz-signature"] = signPostPolicy(policy, signingDate.Format("20060102"), s.awsRegion, s.awsSecret)
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
if err := w.SetBoundary("--custom-post-boundary"); err != nil {
return fmt.Errorf("failed to set boundary: %w", err)
}
for k, v := range fields {
if err := w.WriteField(k, v); err != nil {
return err
}
}
fw, err := w.CreateFormFile("file", "upload.bin")
if err != nil {
return err
}
if _, err = fw.Write(objData); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
boundary := w.Boundary()
body := buf.Bytes()
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/%s", s.endpoint, bucket), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", fmt.Sprintf("multipart/form-data; boundary=%s", boundary))
req.ContentLength = int64(len(body))
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("expected status 204, got %d", resp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
res, err := s3client.GetObject(ctx, &s3.GetObjectInput{
Bucket: &bucket,
Key: &key,
})
cancel()
if err != nil {
return err
}
defer res.Body.Close()
gotData, err := io.ReadAll(res.Body)
if err != nil {
return err
}
if !bytes.Equal(gotData, objData) {
return fmt.Errorf("expected object data %q, got %q", objData, gotData)
}
return nil
})
}