Files
versitygw/s3api/controllers/bucket-post_test.go
T
niksis02 aa1e82b66c fix: require s3:PutObjectTagging for tagged browser-based POST object uploads
`POSTObject` authorized only `s3:PutObject`, even when the form's `tagging` field set tags on the uploaded object. `PutObject`, `CopyObject` and `CreateMultipartUpload` also require `s3:PutObjectTagging` when tags are sent, so a caller without that permission could tag objects through `POST` in a way `PutObject` refuses. S3 enforces the permission for `POST` too: a user granted only `s3:PutObject` gets `AccessDenied` for `s3:PutObjectTagging` on the object ARN.

`POSTObject` now adds `s3:PutObjectTagging` to the `verifyAccess` actions when the parsed tag set is not empty, in the same single check as `s3:PutObject`, matching `PutObject`. A `tagging` field with an empty `TagSet` still needs only `s3:PutObject`, as on S3.

The access check now runs after the form is validated, instead of before the `POST` policy is parsed. S3 validates the policy and its conditions, the tagging, the checksum fields, the metadata size and the website redirect location before it authorizes the upload, so a caller missing a permission now gets the validation error for an invalid form instead of `AccessDenied`.
2026-09-24 22:31:08 +04:00

875 lines
27 KiB
Go

// Copyright 2023 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 controllers
import (
"context"
"encoding/base64"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/stretchr/testify/assert"
"github.com/versity/versitygw/auth"
"github.com/versity/versitygw/s3api/middlewares"
"github.com/versity/versitygw/s3api/utils"
"github.com/versity/versitygw/s3err"
"github.com/versity/versitygw/s3event"
"github.com/versity/versitygw/s3response"
)
func TestS3ApiController_DeleteObjects(t *testing.T) {
validBody, err := xml.Marshal(s3response.DeleteObjects{
Objects: []types.ObjectIdentifier{
{Key: utils.GetStringPtr("obj")},
},
})
assert.NoError(t, err)
validRes := s3response.DeleteResult{
Deleted: []types.DeletedObject{
{Key: utils.GetStringPtr("obj")},
},
}
partialSuccessBody, err := xml.Marshal(s3response.DeleteObjects{
Objects: []types.ObjectIdentifier{
{Key: utils.GetStringPtr("locked")},
{Key: utils.GetStringPtr("ok")},
},
})
assert.NoError(t, err)
// 1000 keys is the S3 limit for a single DeleteObjects request; 1001
// must be rejected before any of them reaches the backend. The counts
// are spelled out rather than derived from the limit constant so the
// test pins the documented boundary.
const keyLimit = 1000
keyLimitObjs := make([]types.ObjectIdentifier, keyLimit+1)
for i := range keyLimitObjs {
keyLimitObjs[i] = types.ObjectIdentifier{Key: utils.GetStringPtr(fmt.Sprintf("key-%d", i))}
}
atLimitBody, err := xml.Marshal(s3response.DeleteObjects{Objects: keyLimitObjs[:keyLimit]})
assert.NoError(t, err)
overLimitBody, err := xml.Marshal(s3response.DeleteObjects{Objects: keyLimitObjs})
assert.NoError(t, err)
emptyBody, err := xml.Marshal(s3response.DeleteObjects{Objects: []types.ObjectIdentifier{}})
assert.NoError(t, err)
lockConfig, err := json.Marshal(auth.BucketLockConfig{Enabled: true})
assert.NoError(t, err)
legalHoldOn, legalHoldOff := true, false
lockedObjectCode := "AccessDenied"
lockedObjectMessage := "Access Denied because object protected by object lock."
tests := []struct {
name string
input testInput
output testOutput
configureMock func(be *BackendMock)
}{
{
name: "verify access fails",
input: testInput{
locals: accessDeniedLocals,
body: validBody,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "invalid request body",
input: testInput{
locals: defaultLocals,
body: []byte("invalid_body"),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrInvalidRequest),
},
},
{
name: "check object access returns error",
input: testInput{
locals: defaultLocals,
body: validBody,
extraMockErr: s3err.GetAPIError(s3err.ErrObjectLocked),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrObjectLocked),
},
},
{
name: "backend returns error",
input: testInput{
locals: defaultLocals,
beRes: s3response.DeleteResult{},
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
body: validBody,
extraMockErr: s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound),
},
output: testOutput{
response: &Response{
Data: s3response.DeleteResult{},
MetaOpts: &MetaOptions{
BucketOwner: "root",
EventName: s3event.EventObjectRemovedDeleteObjects,
ObjectCount: 1,
},
},
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
},
},
{
name: "successful response",
input: testInput{
locals: defaultLocals,
body: validBody,
beRes: validRes,
extraMockErr: s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound),
},
output: testOutput{
response: &Response{
Data: validRes,
MetaOpts: &MetaOptions{
BucketOwner: "root",
EventName: s3event.EventObjectRemovedDeleteObjects,
ObjectCount: 1,
},
},
},
},
{
name: "empty delete list",
input: testInput{
locals: defaultLocals,
body: emptyBody,
extraMockErr: s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound),
},
output: testOutput{
response: &Response{
Data: s3response.DeleteResult{},
MetaOpts: &MetaOptions{
BucketOwner: "root",
EventName: s3event.EventObjectRemovedDeleteObjects,
ObjectCount: 0,
},
},
},
},
{
name: "exactly at the 1000 key limit",
input: testInput{
locals: defaultLocals,
body: atLimitBody,
extraMockErr: s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound),
},
output: testOutput{
response: &Response{
Data: s3response.DeleteResult{},
MetaOpts: &MetaOptions{
BucketOwner: "root",
EventName: s3event.EventObjectRemovedDeleteObjects,
ObjectCount: 1000,
},
},
},
configureMock: func(be *BackendMock) {
be.DeleteObjectsFunc = func(contextMoqParam context.Context, deleteObjectsInput *s3.DeleteObjectsInput) (s3response.DeleteResult, error) {
assert.Len(t, deleteObjectsInput.Delete.Objects, 1000)
return s3response.DeleteResult{}, nil
}
},
},
{
name: "over the 1000 key limit",
input: testInput{
locals: defaultLocals,
body: overLimitBody,
extraMockErr: s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrMalformedXML),
},
configureMock: func(be *BackendMock) {
be.DeleteObjectsFunc = func(contextMoqParam context.Context, deleteObjectsInput *s3.DeleteObjectsInput) (s3response.DeleteResult, error) {
t.Error("backend DeleteObjects called for an over-limit request")
return s3response.DeleteResult{}, nil
}
},
},
{
name: "partial success: one object locked, one succeeds",
input: testInput{
locals: defaultLocals,
body: partialSuccessBody,
},
output: testOutput{
response: &Response{
Data: s3response.DeleteResult{
Deleted: []types.DeletedObject{
{Key: utils.GetStringPtr("ok")},
},
Error: []types.Error{
{Key: utils.GetStringPtr("locked"), Code: &lockedObjectCode, Message: &lockedObjectMessage},
},
},
MetaOpts: &MetaOptions{
BucketOwner: "root",
EventName: s3event.EventObjectRemovedDeleteObjects,
ObjectCount: 2,
},
},
},
configureMock: func(be *BackendMock) {
be.GetObjectLockConfigurationFunc = func(contextMoqParam context.Context, bucket string) ([]byte, error) {
return lockConfig, nil
}
be.GetBucketVersioningFunc = func(contextMoqParam context.Context, bucket string) (s3response.GetBucketVersioningOutput, error) {
return s3response.GetBucketVersioningOutput{}, nil
}
be.GetObjectRetentionFunc = func(contextMoqParam context.Context, bucket, object, versionId string) ([]byte, error) {
return []byte("{}"), nil
}
be.GetObjectLegalHoldFunc = func(contextMoqParam context.Context, bucket, object, versionId string) (*bool, error) {
if object == "locked" {
return &legalHoldOn, nil
}
return &legalHoldOff, nil
}
be.DeleteObjectsFunc = func(contextMoqParam context.Context, deleteObjectsInput *s3.DeleteObjectsInput) (s3response.DeleteResult, error) {
// Only the object that cleared the lock check should
// ever reach the backend.
assert.Len(t, deleteObjectsInput.Delete.Objects, 1)
assert.Equal(t, "ok", *deleteObjectsInput.Delete.Objects[0].Key)
return s3response.DeleteResult{
Deleted: []types.DeletedObject{{Key: utils.GetStringPtr("ok")}},
}, nil
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
be := &BackendMock{
DeleteObjectsFunc: func(contextMoqParam context.Context, deleteObjectsInput *s3.DeleteObjectsInput) (s3response.DeleteResult, error) {
return tt.input.beRes.(s3response.DeleteResult), tt.input.beErr
},
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
},
GetObjectLockConfigurationFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
return nil, tt.input.extraMockErr
},
}
if tt.configureMock != nil {
tt.configureMock(be)
}
ctrl := S3ApiController{
be: be,
}
testController(
t,
ctrl.DeleteObjects,
tt.output.response,
tt.output.err,
ctxInputs{
locals: tt.input.locals,
body: tt.input.body,
})
})
}
}
// mockMpFileReader wraps an io.Reader and satisfies utils.MpFileReader.
// It tracks the number of bytes delivered to callers so that Length returns
// the same value that finalFileReader.Length would return after a real upload.
type mockMpFileReader struct {
r io.Reader
bytesRead int64
}
func (m *mockMpFileReader) Read(p []byte) (int, error) {
n, err := m.r.Read(p)
m.bytesRead += int64(n)
return n, err
}
func (m *mockMpFileReader) Length() int64 { return m.bytesRead }
func newMockFileReader(content string) *mockMpFileReader {
return &mockMpFileReader{r: strings.NewReader(content)}
}
func TestS3ApiController_POSTObject(t *testing.T) {
encodePOSTPolicyForControllerTest := func(t *testing.T, expiration time.Time, conditions []any) string {
t.Helper()
policy := map[string]any{
"expiration": expiration.UTC().Format(time.RFC3339),
"conditions": conditions,
}
b, err := json.Marshal(policy)
assert.NoError(t, err)
return base64.StdEncoding.EncodeToString(b)
}
postObjectLocalsForTest := func(parsed middlewares.PostObjectResult) map[utils.ContextKey]any {
return map[utils.ContextKey]any{
utils.ContextKeyIsRoot: true,
utils.ContextKeyParsedAcl: auth.ACL{
Owner: "root",
},
utils.ContextKeyAccount: auth.Account{
Access: "root",
Role: auth.RoleAdmin,
},
utils.ContextKeyRegion: "us-east-1",
utils.ContextKeyObjectPostResult: parsed,
}
}
marshalObjectTaggingForControllerTest := func(t *testing.T, tags []s3response.Tag) string {
t.Helper()
data, err := xml.Marshal(s3response.Tagging{
TagSet: s3response.TagSet{
Tags: tags,
},
})
assert.NoError(t, err)
return string(data)
}
validTaggingXML := marshalObjectTaggingForControllerTest(t, []s3response.Tag{
{Key: "project", Value: "alpha team"},
})
baseFields := map[string]string{
"key": "uploads/photo.jpg",
"file": "ignored",
"x-amz-signature": "ignored",
}
basePolicy := encodePOSTPolicyForControllerTest(t, time.Now().Add(15*time.Minute), []any{
map[string]string{"bucket": "bucket"},
[]any{"starts-with", "$key", "uploads/"},
})
baseFields["policy"] = basePolicy
location := "http://example.com/bucket/uploads%2Fphoto.jpg"
tests := []struct {
name string
input testInput
output testOutput
}{
{
name: "verify access fails",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyIsRoot: false,
utils.ContextKeyParsedAcl: auth.ACL{
Owner: "user",
},
utils.ContextKeyAccount: auth.Account{
Access: "user",
Role: auth.RoleUser,
},
utils.ContextKeyRegion: "us-east-1",
utils.ContextKeyObjectPostResult: middlewares.PostObjectResult{
Fields: baseFields,
FileRdr: newMockFileReader("payload"),
ContentLength: int64(len("payload")),
},
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "user",
},
},
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "invalid policy",
input: testInput{
locals: postObjectLocalsForTest(middlewares.PostObjectResult{
Fields: map[string]string{
"key": "uploads/photo.jpg",
"policy": "%%%not-base64%%%",
"file": "ignored",
"x-amz-signature": "ignored",
},
FileRdr: newMockFileReader("payload"),
ContentLength: int64(len("payload")),
}),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.InvalidPolicyDocument.InvalidBase64Encoding(),
},
},
{
name: "policy evaluation fails on extra field",
input: testInput{
locals: postObjectLocalsForTest(middlewares.PostObjectResult{
Fields: map[string]string{
"key": "uploads/photo.jpg",
"policy": basePolicy,
"file": "ignored",
"x-amz-signature": "ignored",
"unexpected": "value",
},
FileRdr: newMockFileReader("payload"),
ContentLength: int64(len("payload")),
}),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.InvalidPolicyDocument.ExtraInputField("unexpected"),
},
},
{
name: "invalid tagging xml",
input: testInput{
locals: postObjectLocalsForTest(middlewares.PostObjectResult{
Fields: map[string]string{
"key": "uploads/photo.jpg",
"policy": encodePOSTPolicyForControllerTest(t, time.Now().Add(15*time.Minute), []any{
map[string]string{"bucket": "bucket"},
[]any{"starts-with", "$key", "uploads/"},
[]any{"eq", "$tagging", "invalid-xml"},
}),
"file": "ignored",
"x-amz-signature": "ignored",
"tagging": "invalid-xml",
},
FileRdr: newMockFileReader("payload"),
ContentLength: int64(len("payload")),
}),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrMalformedXML),
},
},
{
name: "invalid checksum fields",
input: testInput{
locals: postObjectLocalsForTest(middlewares.PostObjectResult{
Fields: map[string]string{
"key": "uploads/photo.jpg",
"policy": encodePOSTPolicyForControllerTest(t, time.Now().Add(15*time.Minute), []any{
map[string]string{"bucket": "bucket"},
[]any{"starts-with", "$key", "uploads/"},
[]any{"eq", "$x-amz-checksum-crc32", "invalid_base64_string"},
}),
"file": "ignored",
"x-amz-signature": "ignored",
"x-amz-checksum-crc32": "invalid_base64_string",
},
FileRdr: newMockFileReader("payload"),
ContentLength: int64(len("payload")),
}),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetInvalidChecksumHeaderErr("x-amz-checksum-crc32"),
},
},
{
name: "metadata too large",
input: testInput{
locals: postObjectLocalsForTest(middlewares.PostObjectResult{
Fields: map[string]string{
"key": "uploads/photo.jpg",
"policy": encodePOSTPolicyForControllerTest(t, time.Now().Add(15*time.Minute), []any{
map[string]string{"bucket": "bucket"},
[]any{"starts-with", "$key", "uploads/"},
[]any{"starts-with", "$x-amz-meta-big", ""},
}),
"file": "ignored",
"x-amz-signature": "ignored",
"x-amz-meta-big": strings.Repeat("a", 2050),
},
FileRdr: newMockFileReader("payload"),
ContentLength: int64(len("payload")),
}),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetMetadataTooLargeErr(2053, 2048),
},
},
{
name: "overwriting a locked object",
input: testInput{
// object lock enabled on the bucket; the mock reports a
// legal hold on every object
extraMockResp: []byte(`{"Enabled":true}`),
locals: postObjectLocalsForTest(middlewares.PostObjectResult{
Fields: baseFields,
FileRdr: newMockFileReader("payload"),
ContentLength: int64(len("payload")),
}),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrObjectLocked),
},
},
{
name: "backend returns error",
input: testInput{
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
locals: postObjectLocalsForTest(middlewares.PostObjectResult{
Fields: baseFields,
FileRdr: newMockFileReader("payload"),
ContentLength: int64(len("payload")),
}),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
},
},
{
name: "successful redirect response",
input: testInput{
beRes: s3response.PutObjectOutput{
ETag: "etag-123",
VersionID: "vid-123",
},
locals: postObjectLocalsForTest(middlewares.PostObjectResult{
Fields: map[string]string{
"key": "uploads/photo.jpg",
"policy": encodePOSTPolicyForControllerTest(t, time.Now().Add(15*time.Minute), []any{
map[string]string{"bucket": "bucket"},
[]any{"starts-with", "$key", "uploads/"},
[]any{"eq", "$success_action_redirect", "https://client.example/upload-complete"},
}),
"file": "ignored",
"x-amz-signature": "ignored",
"success_action_redirect": "https://client.example/upload-complete",
},
FileRdr: newMockFileReader("payload"),
ContentLength: int64(len("payload")),
}),
},
output: testOutput{
response: &Response{
Headers: map[string]*string{
"Location": utils.GetStringPtr("https://client.example/upload-complete?bucket=bucket&etag=etag-123&key=uploads%2Fphoto.jpg"),
},
MetaOpts: &MetaOptions{
BucketOwner: "root",
ContentLength: int64(len("payload")),
ObjectETag: utils.GetStringPtr("etag-123"),
ObjectSize: int64(len("payload")),
EventName: s3event.EventObjectCreatedPost,
Status: 303,
},
},
},
},
{
name: "successful created response",
input: testInput{
beRes: s3response.PutObjectOutput{
ETag: "etag-123",
VersionID: "vid-123",
ChecksumCRC32: utils.GetStringPtr("crc32-out"),
ChecksumType: types.ChecksumTypeComposite,
},
locals: postObjectLocalsForTest(middlewares.PostObjectResult{
Fields: map[string]string{
"key": "uploads/photo.jpg",
"policy": encodePOSTPolicyForControllerTest(t, time.Now().Add(15*time.Minute), []any{
map[string]string{"bucket": "bucket"},
[]any{"starts-with", "$key", "uploads/"},
[]any{"eq", "$success_action_status", "201"},
[]any{"eq", "$tagging", validTaggingXML},
[]any{"eq", "$x-amz-meta-owner", "alice"},
[]any{"eq", "$x-amz-checksum-crc32", "ww2FVQ=="},
[]any{"eq", "$cache-control", "max-age=60"},
[]any{"eq", "$content-type", "image/jpeg"},
[]any{"eq", "$content-disposition", "inline"},
[]any{"eq", "$content-encoding", "gzip"},
[]any{"eq", "$content-language", "en-US"},
[]any{"eq", "$expires", "Fri, 21 Mar 2026 00:00:00 GMT"},
[]any{"eq", "$x-amz-storage-class", string(types.StorageClassGlacier)},
}),
"file": "ignored",
"x-amz-signature": "ignored",
"success_action_status": "201",
"tagging": validTaggingXML,
"x-amz-meta-owner": "alice",
"x-amz-checksum-crc32": "ww2FVQ==",
"cache-control": "max-age=60",
"content-type": "image/jpeg",
"content-disposition": "inline",
"content-encoding": "gzip",
"content-language": "en-US",
"expires": "Fri, 21 Mar 2026 00:00:00 GMT",
"x-amz-storage-class": string(types.StorageClassGlacier),
},
FileRdr: newMockFileReader("payload"),
ContentLength: int64(len("payload")),
}),
},
output: testOutput{
response: &Response{
Headers: map[string]*string{
"Etag": utils.GetStringPtr("etag-123"),
"Location": &location,
"x-amz-checksum-crc32": utils.GetStringPtr("crc32-out"),
"x-amz-checksum-crc32c": utils.GetStringPtr(""),
"x-amz-checksum-crc64nvme": utils.GetStringPtr(""),
"x-amz-checksum-sha1": utils.GetStringPtr(""),
"x-amz-checksum-sha256": utils.GetStringPtr(""),
"x-amz-checksum-sha512": utils.GetStringPtr(""),
"x-amz-checksum-md5": utils.GetStringPtr(""),
"x-amz-checksum-xxhash64": utils.GetStringPtr(""),
"x-amz-checksum-xxhash3": utils.GetStringPtr(""),
"x-amz-checksum-xxhash128": utils.GetStringPtr(""),
"x-amz-checksum-type": utils.GetStringPtr(string(types.ChecksumTypeComposite)),
"x-amz-version-id": utils.GetStringPtr("vid-123"),
},
Data: &s3response.PostResponse{
Bucket: "bucket",
Key: "uploads/photo.jpg",
ETag: "etag-123",
Location: location,
},
MetaOpts: &MetaOptions{
BucketOwner: "root",
ContentLength: int64(len("payload")),
ObjectETag: utils.GetStringPtr("etag-123"),
ObjectSize: int64(len("payload")),
EventName: s3event.EventObjectCreatedPost,
Status: http.StatusCreated,
},
},
},
},
{
name: "default content type when field is omitted",
input: testInput{
beRes: s3response.PutObjectOutput{
ETag: "etag-123",
},
locals: postObjectLocalsForTest(middlewares.PostObjectResult{
Fields: baseFields,
FileRdr: newMockFileReader("payload"),
ContentLength: int64(len("payload")),
}),
},
output: testOutput{
response: &Response{
Headers: map[string]*string{
"Etag": utils.GetStringPtr("etag-123"),
"Location": &location,
"x-amz-checksum-crc32": nil,
"x-amz-checksum-crc32c": nil,
"x-amz-checksum-crc64nvme": nil,
"x-amz-checksum-sha1": nil,
"x-amz-checksum-sha256": nil,
"x-amz-checksum-sha512": nil,
"x-amz-checksum-md5": nil,
"x-amz-checksum-xxhash64": nil,
"x-amz-checksum-xxhash3": nil,
"x-amz-checksum-xxhash128": nil,
"x-amz-checksum-type": nil,
"x-amz-version-id": nil,
},
MetaOpts: &MetaOptions{
BucketOwner: "root",
ContentLength: int64(len("payload")),
ObjectETag: utils.GetStringPtr("etag-123"),
ObjectSize: int64(len("payload")),
EventName: s3event.EventObjectCreatedPost,
Status: http.StatusNoContent,
},
},
},
},
{
name: "anonymous upload with policy is evaluated",
input: testInput{
locals: postObjectLocalsForTest(middlewares.PostObjectResult{
Fields: map[string]string{
"key": "uploads/anon.bin",
"policy": encodePOSTPolicyForControllerTest(t, time.Now().Add(15*time.Minute), []any{
map[string]string{"bucket": "bucket"},
// key condition intentionally omitted -> ExtraInputField for "key"
}),
"file": "ignored",
},
FileRdr: newMockFileReader("payload"),
ContentLength: int64(len("payload")),
}),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.InvalidPolicyDocument.ExtraInputField("key"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
be := &BackendMock{
PutObjectFunc: func(contextMoqParam context.Context, putObjectInput s3response.PutObjectInput) (s3response.PutObjectOutput, error) {
if tt.input.beErr != nil {
return s3response.PutObjectOutput{}, tt.input.beErr
}
// Drain the body as a real backend would, so that FileRdr.Length()
// reflects the actual bytes written after PutObject returns.
body, err := io.ReadAll(putObjectInput.Body)
assert.NoError(t, err)
if tt.name == "anonymous upload succeeds without policy" {
assert.Equal(t, "uploads/anon.bin", *putObjectInput.Key)
assert.Equal(t, "anon-payload", string(body))
}
if tt.name == "default content type when field is omitted" {
assert.Equal(t, defaultContentType, *putObjectInput.ContentType)
}
if tt.name == "successful created response" {
assert.Equal(t, "bucket", *putObjectInput.Bucket)
assert.Equal(t, "uploads/photo.jpg", *putObjectInput.Key)
assert.Equal(t, "image/jpeg", *putObjectInput.ContentType)
assert.Equal(t, "gzip", *putObjectInput.ContentEncoding)
assert.Equal(t, "inline", *putObjectInput.ContentDisposition)
assert.Equal(t, "en-US", *putObjectInput.ContentLanguage)
assert.Equal(t, "max-age=60", *putObjectInput.CacheControl)
assert.Equal(t, "Fri, 21 Mar 2026 00:00:00 GMT", *putObjectInput.Expires)
assert.Equal(t, types.StorageClassGlacier, putObjectInput.StorageClass)
assert.Equal(t, int64(len("payload")), *putObjectInput.ContentLength)
assert.Equal(t, "project=alpha+team", *putObjectInput.Tagging)
assert.Equal(t, map[string]string{"owner": "alice"}, putObjectInput.Metadata)
assert.Equal(t, utils.GetStringPtr("ww2FVQ=="), putObjectInput.ChecksumCRC32)
assert.Equal(t, "payload", string(body))
}
return tt.input.beRes.(s3response.PutObjectOutput), nil
},
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
},
GetBucketVersioningFunc: func(contextMoqParam context.Context, bucket string) (s3response.GetBucketVersioningOutput, error) {
return s3response.GetBucketVersioningOutput{}, s3err.GetAPIError(s3err.ErrNotImplemented)
},
GetObjectLockConfigurationFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
if tt.input.extraMockResp != nil {
return tt.input.extraMockResp.([]byte), nil
}
return nil, s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound)
},
GetObjectRetentionFunc: func(contextMoqParam context.Context, bucket, object, versionId string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrNoSuchObjectLockConfiguration)
},
GetObjectLegalHoldFunc: func(contextMoqParam context.Context, bucket, object, versionId string) (*bool, error) {
legalHold := true
return &legalHold, nil
},
}
ctrl := S3ApiController{
be: be,
}
testController(
t,
ctrl.POSTObject,
tt.output.response,
tt.output.err,
ctxInputs{
locals: tt.input.locals,
},
)
})
}
}