Files
versitygw/s3api/controllers/object-put_test.go
T
niksis02 1e0d7b218d fix: reject empty versionId query parameter in object actions
S3 returns `InvalidArgument` when an object action receives a `versionId` query parameter with no value. The gateway silently treated it as an unversioned request instead.

Added a shared `versionId` validation helper and apply it to the object actions that accept the parameter, so malformed requests are rejected up front rather than reaching the backend.
2026-09-10 16:35:05 +04:00

1521 lines
38 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/xml"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/oklog/ulid/v2"
"github.com/stretchr/testify/assert"
"github.com/versity/versitygw/auth"
"github.com/versity/versitygw/s3api/utils"
"github.com/versity/versitygw/s3err"
"github.com/versity/versitygw/s3event"
"github.com/versity/versitygw/s3response"
)
func TestS3ApiController_PutObjectTagging(t *testing.T) {
validTaggingBody, err := xml.Marshal(
s3response.Tagging{
TagSet: s3response.TagSet{
Tags: []s3response.Tag{
{
Key: "key",
Value: "val",
},
},
},
})
assert.NoError(t, err)
versionId := ulid.Make().String()
tests := []struct {
name string
input testInput
output testOutput
}{
{
name: "empty versionId query param",
input: testInput{
locals: defaultLocals,
queries: map[string]string{
"versionId": "",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgEmptyVersionId, ""),
},
},
{
name: "verify access fails",
input: testInput{
locals: accessDeniedLocals,
},
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.ErrMalformedXML),
},
},
{
name: "backend returns error",
input: testInput{
locals: defaultLocals,
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
body: validTaggingBody,
queries: map[string]string{
"versionId": versionId,
},
},
output: testOutput{
response: &Response{
Headers: map[string]*string{
"x-amz-version-id": &versionId,
},
MetaOpts: &MetaOptions{
BucketOwner: "root",
EventName: s3event.EventObjectTaggingPut,
},
},
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
},
},
{
name: "successful response",
input: testInput{
locals: defaultLocals,
body: validTaggingBody,
queries: map[string]string{
"versionId": versionId,
},
},
output: testOutput{
response: &Response{
Headers: map[string]*string{
"x-amz-version-id": &versionId,
},
MetaOpts: &MetaOptions{
BucketOwner: "root",
EventName: s3event.EventObjectTaggingPut,
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
be := &BackendMock{
PutObjectTaggingFunc: func(contextMoqParam context.Context, bucket, object, versionId string, tags map[string]string) error {
return tt.input.beErr
},
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
},
}
ctrl := S3ApiController{
be: be,
}
testController(
t,
ctrl.PutObjectTagging,
tt.output.response,
tt.output.err,
ctxInputs{
locals: tt.input.locals,
body: tt.input.body,
queries: tt.input.queries,
})
})
}
}
func TestS3ApiController_PutObjectRetention(t *testing.T) {
retDate := time.Now().Add(time.Hour * 3)
validRetentionBody, err := xml.Marshal(
s3response.PutObjectRetentionInput{
Mode: types.ObjectLockRetentionModeGovernance,
RetainUntilDate: s3response.AmzDate{
Time: retDate,
},
})
assert.NoError(t, err)
tests := []struct {
name string
input testInput
output testOutput
}{
{
name: "empty versionId query param",
input: testInput{
locals: defaultLocals,
queries: map[string]string{
"versionId": "",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgEmptyVersionId, ""),
},
},
{
name: "verify access fails",
input: testInput{
locals: accessDeniedLocals,
extraMockErr: s3err.GetAPIError(s3err.ErrAccessDenied),
},
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.ErrMalformedXML),
},
},
{
name: "retention put not allowed",
input: testInput{
locals: defaultLocals,
body: validRetentionBody,
extraMockErr: s3err.GetAPIError(s3err.ErrAccessDenied),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "backend returns error",
input: testInput{
locals: defaultLocals,
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
body: validRetentionBody,
extraMockErr: s3err.GetAPIError(s3err.ErrNoSuchObjectLockConfiguration),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
},
},
{
name: "successful response",
input: testInput{
locals: defaultLocals,
body: validRetentionBody,
extraMockErr: s3err.GetAPIError(s3err.ErrNoSuchObjectLockConfiguration),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
be := &BackendMock{
PutObjectRetentionFunc: func(contextMoqParam context.Context, bucket, object, versionId string, retention []byte) error {
return tt.input.beErr
},
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
},
GetObjectRetentionFunc: func(contextMoqParam context.Context, bucket, object, versionId string) ([]byte, error) {
return nil, tt.input.extraMockErr
},
}
ctrl := S3ApiController{
be: be,
}
testController(
t,
ctrl.PutObjectRetention,
tt.output.response,
tt.output.err,
ctxInputs{
locals: tt.input.locals,
body: tt.input.body,
headers: tt.input.headers,
queries: tt.input.queries,
})
})
}
}
func TestS3ApiController_PutObjectLegalHold(t *testing.T) {
validBody, err := xml.Marshal(
types.ObjectLockLegalHold{
Status: types.ObjectLockLegalHoldStatusOn,
})
assert.NoError(t, err)
invalidStatusBody, err := xml.Marshal(
types.ObjectLockLegalHold{
Status: types.ObjectLockLegalHoldStatus("invalid_status"),
})
assert.NoError(t, err)
tests := []struct {
name string
input testInput
output testOutput
}{
{
name: "empty versionId query param",
input: testInput{
locals: defaultLocals,
queries: map[string]string{
"versionId": "",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgEmptyVersionId, ""),
},
},
{
name: "verify access fails",
input: testInput{
locals: accessDeniedLocals,
},
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.ErrMalformedXML),
},
},
{
name: "invalid legal hold status",
input: testInput{
locals: defaultLocals,
body: invalidStatusBody,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrMalformedXML),
},
},
{
name: "backend returns error",
input: testInput{
locals: defaultLocals,
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
body: validBody,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
},
},
{
name: "successful response",
input: testInput{
locals: defaultLocals,
body: validBody,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
be := &BackendMock{
PutObjectLegalHoldFunc: func(contextMoqParam context.Context, bucket, object, versionId string, status bool) error {
return tt.input.beErr
},
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
},
}
ctrl := S3ApiController{
be: be,
}
testController(
t,
ctrl.PutObjectLegalHold,
tt.output.response,
tt.output.err,
ctxInputs{
locals: tt.input.locals,
body: tt.input.body,
queries: tt.input.queries,
})
})
}
}
func TestS3ApiController_UploadPart(t *testing.T) {
tests := []struct {
name string
input testInput
output testOutput
}{
{
name: "verify access fails",
input: testInput{
locals: accessDeniedLocals,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "invalid part number",
input: testInput{
locals: defaultLocals,
queries: map[string]string{
"partNumber": "-2",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgPartNumber, "-2"),
},
},
{
name: "invalid content length",
input: testInput{
locals: defaultLocals,
headers: map[string]string{
"X-Amz-Decoded-Content-Length": "invalid_cLength",
},
queries: map[string]string{
"partNumber": "2",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrInvalidRequest),
},
},
{
name: "invalid checksum header",
input: testInput{
locals: defaultLocals,
headers: map[string]string{
"X-Amz-Sdk-Checksum-Algorithm": "invalid_algo",
},
queries: map[string]string{
"partNumber": "2",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrInvalidChecksumAlgorithm),
},
},
{
name: "backend returns error",
input: testInput{
locals: defaultLocals,
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
beRes: &s3.UploadPartOutput{},
queries: map[string]string{
"partNumber": "2",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
},
},
{
name: "successful response",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyIsRoot: true,
utils.ContextKeyParsedAcl: auth.ACL{
Owner: "root",
},
utils.ContextKeyAccount: auth.Account{
Access: "root",
Role: auth.RoleAdmin,
},
utils.ContextKeyBodyReader: strings.NewReader("hello world"),
},
queries: map[string]string{
"partNumber": "2",
},
headers: map[string]string{
"Content-Length": "4",
},
body: []byte("bbbb"),
beRes: &s3.UploadPartOutput{
ETag: utils.GetStringPtr("ETag"),
},
},
output: testOutput{
response: &Response{
Headers: map[string]*string{
"ETag": utils.GetStringPtr("ETag"),
"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,
},
MetaOpts: &MetaOptions{
BucketOwner: "root",
ContentLength: 4,
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
be := &BackendMock{
UploadPartFunc: func(contextMoqParam context.Context, uploadPartInput *s3.UploadPartInput) (*s3.UploadPartOutput, error) {
return tt.input.beRes.(*s3.UploadPartOutput), tt.input.beErr
},
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
},
}
ctrl := S3ApiController{
be: be,
mpMaxParts: 10000,
}
testController(
t,
ctrl.UploadPart,
tt.output.response,
tt.output.err,
ctxInputs{
locals: tt.input.locals,
body: tt.input.body,
headers: tt.input.headers,
queries: tt.input.queries,
})
})
}
}
func TestS3ApiController_UploadPartCopy(t *testing.T) {
tests := []struct {
name string
input testInput
output testOutput
}{
{
name: "verify access fails",
input: testInput{
locals: accessDeniedLocals,
headers: map[string]string{
"X-Amz-Copy-Source": "bucket/key",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "readonly mode blocks upload part copy",
input: testInput{
locals: defaultLocals,
headers: map[string]string{
"X-Amz-Copy-Source": "bucket/key",
},
queries: map[string]string{
"partNumber": "2",
},
readonly: true,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "invalid copy source",
input: testInput{
locals: defaultLocals,
headers: map[string]string{
"X-Amz-Copy-Source": "bad%G1",
},
queries: map[string]string{
"partNumber": "2",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceEncoding, "bad%G1"),
},
},
{
name: "non empty request body",
input: testInput{
locals: defaultLocals,
headers: map[string]string{
"X-Amz-Copy-Source": "bucket/object",
},
queries: map[string]string{
"partNumber": "2",
},
body: []byte("body"),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrNonEmptyRequestBody),
},
},
{
name: "invalid part number",
input: testInput{
locals: defaultLocals,
headers: map[string]string{
"X-Amz-Copy-Source": "bucket/object",
},
queries: map[string]string{
"partNumber": "-2",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgPartNumber, "-2"),
},
},
{
name: "backend returns error",
input: testInput{
locals: defaultLocals,
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
beRes: s3response.CopyPartResult{},
queries: map[string]string{
"partNumber": "2",
},
headers: map[string]string{
"X-Amz-Copy-Source": "bucket/object",
},
},
output: testOutput{
response: &Response{
Data: s3response.CopyPartResult{},
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
},
},
{
name: "successful response",
input: testInput{
locals: defaultLocals,
queries: map[string]string{
"partNumber": "2",
},
headers: map[string]string{
"X-Amz-Copy-Source": "bucket/object",
},
beRes: s3response.CopyPartResult{
CopySourceVersionId: "versionId",
},
},
output: testOutput{
response: &Response{
Data: s3response.CopyPartResult{
CopySourceVersionId: "versionId",
},
Headers: map[string]*string{
"x-amz-copy-source-version-id": utils.GetStringPtr("versionId"),
},
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
be := &BackendMock{
UploadPartCopyFunc: func(contextMoqParam context.Context, uploadPartCopyInput *s3.UploadPartCopyInput) (s3response.CopyPartResult, error) {
return tt.input.beRes.(s3response.CopyPartResult), tt.input.beErr
},
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
},
}
ctrl := S3ApiController{
be: be,
readonly: tt.input.readonly,
disableACL: tt.input.disableACL,
}
testController(
t,
ctrl.UploadPartCopy,
tt.output.response,
tt.output.err,
ctxInputs{
locals: tt.input.locals,
headers: tt.input.headers,
queries: tt.input.queries,
body: tt.input.body,
})
})
}
}
func TestS3ApiController_PutObjectAcl(t *testing.T) {
tests := []struct {
name string
input testInput
output testOutput
}{
{
name: "verify access fails",
input: testInput{
locals: accessDeniedLocals,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "backend returns error",
input: testInput{
locals: defaultLocals,
beErr: s3err.GetAPIError(s3err.ErrNotImplemented),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
EventName: s3event.EventObjectAclPut,
},
},
err: s3err.GetAPIError(s3err.ErrNotImplemented),
},
},
{
name: "successful response",
input: testInput{
locals: defaultLocals,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
EventName: s3event.EventObjectAclPut,
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
be := &BackendMock{
PutObjectAclFunc: func(contextMoqParam context.Context, putObjectAclInput *s3.PutObjectAclInput) error {
return tt.input.beErr
},
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy)
},
}
ctrl := S3ApiController{
be: be,
}
testController(
t,
ctrl.PutObjectAcl,
tt.output.response,
tt.output.err,
ctxInputs{
locals: tt.input.locals,
})
})
}
}
func TestS3ApiController_CopyObject(t *testing.T) {
var nilResp *s3response.CopyObjectResult
tests := []struct {
name string
input testInput
output testOutput
}{
{
name: "verify access fails",
input: testInput{
locals: accessDeniedLocals,
headers: map[string]string{
"X-Amz-Copy-Source": "bucket/object",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "readonly mode blocks copy object",
input: testInput{
locals: defaultLocals,
headers: map[string]string{
"X-Amz-Copy-Source": "bucket/object",
},
readonly: true,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "disableACL blocks a non-owner's grantee-based access",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyIsRoot: false,
utils.ContextKeyParsedAcl: auth.ACL{
Owner: "root",
Grantees: []auth.Grantee{
{
Access: "user",
Permission: auth.PermissionWrite,
Type: types.TypeCanonicalUser,
},
},
},
utils.ContextKeyAccount: auth.Account{
Access: "user",
Role: auth.RoleUser,
},
},
headers: map[string]string{
"X-Amz-Copy-Source": "bucket/object",
},
disableACL: true,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "invalid copy source",
input: testInput{
locals: defaultLocals,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceBucket, ""),
},
},
{
name: "non empty request body",
input: testInput{
locals: defaultLocals,
headers: map[string]string{
"X-Amz-Copy-Source": "bucket/object",
},
body: []byte("body"),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrNonEmptyRequestBody),
},
},
{
name: "invalid metadata header",
input: testInput{
locals: defaultLocals,
headers: map[string]string{
"X-Amz-Copy-Source": "bucket/object",
"x-amz-meta-key": strings.Repeat("b", 2048),
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetMetadataTooLargeErr(2051, 2048),
},
},
{
name: "invalid metadata directive",
input: testInput{
locals: defaultLocals,
headers: map[string]string{
"X-Amz-Copy-Source": "bucket/object",
"X-Amz-Copy-Source-If-Unmodified-Since": "20250102T150405Z",
"X-Amz-Copy-Source-If-Modified-Since": "20240102T150405Z",
"X-Amz-Metadata-Directive": "invalid_metadat_directive",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgMetadataDirective, "invalid_metadat_directive"),
},
},
{
name: "invalid tagging directive",
input: testInput{
locals: defaultLocals,
headers: map[string]string{
"X-Amz-Copy-Source": "bucket/object",
"X-Amz-Tagging-Directive": "invalid_tagging_directive",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgTaggingDirective, "invalid_tagging_directive"),
},
},
{
name: "invalid checksum algorithm",
input: testInput{
locals: defaultLocals,
headers: map[string]string{
"X-Amz-Copy-Source": "bucket/object",
"x-amz-checksum-algorithm": "invalid_checksum_algo",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrInvalidChecksumAlgorithm),
},
},
{
name: "invalid object lock headers",
input: testInput{
locals: defaultLocals,
headers: map[string]string{
"X-Amz-Copy-Source": "bucket/object",
"X-Amz-Object-Lock-Mode": "GOVERNANCE",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgMissingObjectLockRetainDate, ""),
},
},
{
name: "object is locked",
input: testInput{
locals: defaultLocals,
headers: map[string]string{
"X-Amz-Copy-Source": "bucket/object",
},
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,
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
beRes: s3response.CopyObjectOutput{},
headers: map[string]string{
"X-Amz-Copy-Source": "bucket/object",
},
extraMockErr: s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound),
},
output: testOutput{
response: &Response{
Data: nilResp,
Headers: map[string]*string{
"x-amz-copy-source-version-id": nil,
"x-amz-version-id": nil,
},
MetaOpts: &MetaOptions{
BucketOwner: "root",
EventName: s3event.EventObjectCreatedCopy,
},
},
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
},
},
{
name: "successful response",
input: testInput{
locals: defaultLocals,
headers: map[string]string{
"X-Amz-Copy-Source": "bucket/object",
},
beRes: s3response.CopyObjectOutput{
CopySourceVersionId: utils.GetStringPtr("copySourceVersionId"),
VersionId: utils.GetStringPtr("versionId"),
CopyObjectResult: &s3response.CopyObjectResult{
ETag: utils.GetStringPtr("ETag"),
},
},
extraMockErr: s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound),
},
output: testOutput{
response: &Response{
Data: &s3response.CopyObjectResult{
ETag: utils.GetStringPtr("ETag"),
},
Headers: map[string]*string{
"x-amz-copy-source-version-id": utils.GetStringPtr("copySourceVersionId"),
"x-amz-version-id": utils.GetStringPtr("versionId"),
},
MetaOpts: &MetaOptions{
BucketOwner: "root",
ObjectETag: utils.GetStringPtr("ETag"),
EventName: s3event.EventObjectCreatedCopy,
VersionId: utils.GetStringPtr("versionId"),
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
be := &BackendMock{
CopyObjectFunc: func(contextMoqParam context.Context, copyObjectInput s3response.CopyObjectInput) (s3response.CopyObjectOutput, error) {
return tt.input.beRes.(s3response.CopyObjectOutput), tt.input.beErr
},
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy)
},
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) {
return nil, tt.input.extraMockErr
},
}
ctrl := S3ApiController{
be: be,
readonly: tt.input.readonly,
disableACL: tt.input.disableACL,
}
testController(
t,
ctrl.CopyObject,
tt.output.response,
tt.output.err,
ctxInputs{
locals: tt.input.locals,
headers: tt.input.headers,
body: tt.input.body,
})
})
}
}
func TestS3ApiController_PutObject(t *testing.T) {
str := ""
emptyStringPtr := &str
objSize := int64(120)
t.Run("forwards storage class", func(t *testing.T) {
be := &BackendMock{
PutObjectFunc: func(_ context.Context, input s3response.PutObjectInput) (s3response.PutObjectOutput, error) {
if input.StorageClass != types.StorageClassGlacier {
t.Fatalf("expected storage class %q, got %q", types.StorageClassGlacier, input.StorageClass)
}
return s3response.PutObjectOutput{ETag: "etag", VersionID: "version-id"}, nil
},
GetBucketPolicyFunc: func(_ context.Context, _ string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
},
GetObjectLockConfigurationFunc: func(_ context.Context, _ string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound)
},
GetBucketVersioningFunc: func(_ context.Context, _ string) (s3response.GetBucketVersioningOutput, error) {
return s3response.GetBucketVersioningOutput{}, s3err.GetAPIError(s3err.ErrNotImplemented)
},
}
ctrl := S3ApiController{be: be}
testController(t, ctrl.PutObject, &Response{
Headers: map[string]*string{
"ETag": utils.GetStringPtr("etag"),
"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": utils.GetStringPtr("version-id"),
"x-amz-object-size": nil,
},
MetaOpts: &MetaOptions{
BucketOwner: "root",
ObjectETag: utils.GetStringPtr("etag"),
ContentLength: 0,
ObjectSize: 0,
EventName: s3event.EventObjectCreatedPut,
},
}, nil, ctxInputs{
locals: defaultLocals,
headers: map[string]string{
"X-Amz-Storage-Class": string(types.StorageClassGlacier),
},
})
})
tests := []struct {
name string
input testInput
output testOutput
}{
{
name: "verify access fails",
input: testInput{
locals: accessDeniedLocals,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "locked object",
input: testInput{
locals: defaultLocals,
extraMockErr: s3err.GetAPIError(s3err.ErrInvalidRequest),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrInvalidRequest),
},
},
{
name: "invalid content length",
input: testInput{
locals: defaultLocals,
extraMockErr: s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound),
headers: map[string]string{
"X-Amz-Decoded-Content-Length": "invalid_length",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrInvalidRequest),
},
},
{
name: "invalid metadata header",
input: testInput{
locals: defaultLocals,
extraMockErr: s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound),
headers: map[string]string{
"x-amz-meta-something": strings.Repeat("c", 2050),
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetMetadataTooLargeErr(2059, 2048),
},
},
{
name: "invalid object lock headers",
input: testInput{
locals: defaultLocals,
extraMockErr: s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound),
headers: map[string]string{
"X-Amz-Object-Lock-Mode": "GOVERNANCE",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgMissingObjectLockRetainDate, ""),
},
},
{
name: "invalid checksum headers",
input: testInput{
locals: defaultLocals,
extraMockErr: s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound),
headers: map[string]string{
"X-Amz-Sdk-Checksum-Algorithm": "invalid_algo",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrInvalidChecksumAlgorithm),
},
},
{
name: "backend returns error",
input: testInput{
locals: defaultLocals,
extraMockErr: s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound),
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
beRes: s3response.PutObjectOutput{},
body: []byte("aaa"),
headers: map[string]string{
"Content-Length": "3",
},
},
output: testOutput{
response: &Response{
Headers: map[string]*string{
"ETag": emptyStringPtr,
"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": emptyStringPtr,
"x-amz-object-size": nil,
},
MetaOpts: &MetaOptions{
BucketOwner: "root",
EventName: s3event.EventObjectCreatedPut,
ContentLength: 3,
ObjectSize: 3,
ObjectETag: emptyStringPtr,
},
},
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
},
},
{
name: "successful response",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyIsRoot: true,
utils.ContextKeyParsedAcl: auth.ACL{
Owner: "root",
},
utils.ContextKeyAccount: auth.Account{
Access: "root",
Role: auth.RoleAdmin,
},
utils.ContextKeyBodyReader: strings.NewReader("something"),
},
extraMockErr: s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound),
headers: map[string]string{
"Content-Length": "3",
},
body: []byte("aaa"),
beRes: s3response.PutObjectOutput{
ETag: "ETag",
ChecksumCRC32: utils.GetStringPtr("crc32"),
ChecksumCRC32C: utils.GetStringPtr("crc32c"),
ChecksumSHA1: utils.GetStringPtr("sha1"),
ChecksumSHA256: utils.GetStringPtr("sha256"),
ChecksumCRC64NVME: utils.GetStringPtr("crc64nvme"),
ChecksumSHA512: utils.GetStringPtr("sha512"),
ChecksumMD5: utils.GetStringPtr("md5"),
ChecksumXXHASH64: utils.GetStringPtr("xxhash64"),
ChecksumXXHASH3: utils.GetStringPtr("xxhash3"),
ChecksumXXHASH128: utils.GetStringPtr("xxhash128"),
ChecksumType: types.ChecksumTypeComposite,
VersionID: "versionId",
Size: &objSize,
},
},
output: testOutput{
response: &Response{
Headers: map[string]*string{
"ETag": utils.GetStringPtr("ETag"),
"x-amz-checksum-crc32": utils.GetStringPtr("crc32"),
"x-amz-checksum-crc32c": utils.GetStringPtr("crc32c"),
"x-amz-checksum-crc64nvme": utils.GetStringPtr("crc64nvme"),
"x-amz-checksum-sha1": utils.GetStringPtr("sha1"),
"x-amz-checksum-sha256": utils.GetStringPtr("sha256"),
"x-amz-checksum-sha512": utils.GetStringPtr("sha512"),
"x-amz-checksum-md5": utils.GetStringPtr("md5"),
"x-amz-checksum-xxhash64": utils.GetStringPtr("xxhash64"),
"x-amz-checksum-xxhash3": utils.GetStringPtr("xxhash3"),
"x-amz-checksum-xxhash128": utils.GetStringPtr("xxhash128"),
"x-amz-checksum-type": utils.GetStringPtr(string(types.ChecksumTypeComposite)),
"x-amz-version-id": utils.GetStringPtr("versionId"),
"x-amz-object-size": utils.ConvertToStringPtr(objSize),
},
MetaOpts: &MetaOptions{
BucketOwner: "root",
ObjectETag: utils.GetStringPtr("ETag"),
EventName: s3event.EventObjectCreatedPut,
ContentLength: 3,
ObjectSize: 3,
},
},
},
},
}
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) {
return tt.input.beRes.(s3response.PutObjectOutput), 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
},
GetBucketVersioningFunc: func(contextMoqParam context.Context, bucket string) (s3response.GetBucketVersioningOutput, error) {
return s3response.GetBucketVersioningOutput{}, s3err.GetAPIError(s3err.ErrNotImplemented)
},
}
ctrl := S3ApiController{
be: be,
}
testController(
t,
ctrl.PutObject,
tt.output.response,
tt.output.err,
ctxInputs{
locals: tt.input.locals,
headers: tt.input.headers,
body: tt.input.body,
})
})
}
}