Files
versitygw/tests/integration/PostObject.go
niksis02 a25408c225 fix: remove POST object multipart boundary prefix trimming
Fixes the [comment](https://github.com/versity/versitygw/issues/1648#issuecomment-4175425099)

Removes the unnecessary multipart/form-data boundary normalizing. The boundary prefix(`--`) was trimmed in `NewMultipartParser`, which caused incorrect boundary check for the boundaries starting with 2 dashes(e.g. `----WebKitFormBoundaryABC123`).
2026-04-02 17:20:23 +04:00

1181 lines
36 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.GetAPIError(s3err.ErrPreconditionFailed))
})
}
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.GetAPIError(s3err.ErrOnlyAws4HmacSha256))
})
}
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.GetAPIError(s3err.ErrInvalidDateHeader))
})
}
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())
})
}
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
}
return checkHTTPResponseApiErr(resp, s3err.PostAuth.IncorrectRegion(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 {
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: "test-object",
s3Conf: s,
access: "this_access_key_id_can_not_really_exist",
secret: "a_very_secure_secret_access_key",
fileContent: []byte("data"),
})
if err != nil {
return err
}
return checkHTTPResponseApiErr(resp, s3err.GetAPIError(s3err.ErrInvalidAccessKeyID))
})
}
func PostObject_signature_mismatch(s *S3Conf) error {
testName := "PostObject_signature_mismatch"
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-signature": "incorrect_signature",
},
})
if err != nil {
return err
}
defer resp.Body.Close()
return checkHTTPResponseApiErr(resp, s3err.GetAPIError(s3err.ErrSignatureDoesNotMatch))
})
}
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.APIError
}{
// 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.GetAPIError(s3err.ErrEntityTooLarge))
})
}
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.GetAPIError(s3err.ErrEntityTooSmall))
})
}
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="},
} {
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
})
}