Files
versitygw/s3api/controllers/bucket-put_test.go
T
915fc7a20f fix: return InvalidArgument for an empty website configuration
* fix: return InvalidArgument for an empty website configuration

`PutBucketWebsite` parsed an empty `<WebsiteConfiguration/>` into a zero-value config, and `WebsiteConfiguration.Validate` reported the nil `IndexDocument` as `MalformedXML`. S3 reports the missing index document as an `InvalidArgument` request error naming the argument instead:

    InvalidArgument: A value for IndexDocument Suffix must be provided if RedirectAllRequestsTo is empty

That branch now returns the error with `ArgumentName=IndexDocument` and `ArgumentValue=null`, matching the response in the report. Genuinely malformed XML, and a `RedirectAllRequestsTo` that conflicts with the other fields, still return `MalformedXML`.

Fixes #2260

* test: cover an empty website configuration in the PutBucketWebsite integration suite

Add a `PutBucketWebsite_empty_configuration` case that sends the empty `<WebsiteConfiguration/>` payload from the report and asserts the `InvalidArgument` response, including `ArgumentName=IndexDocument` and `ArgumentValue=null`.

The SDK collapses the error into a generic API error that drops the argument fields, so the request is signed by hand and the raw response checked with `checkHTTPResponseApiErr`, the same helper the other argument-field assertions use. The case fails against the previous behaviour with `expected error code to be InvalidArgument, instead got MalformedXML`.

---------

Co-authored-by: Tung Lam <lamphamabtung96@gmail.com>
2026-09-20 11:04:22 -07:00

1445 lines
33 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"
"errors"
"fmt"
"net/http"
"testing"
"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/utils"
"github.com/versity/versitygw/s3err"
"github.com/versity/versitygw/s3response"
)
func TestS3ApiController_PutBucketTagging(t *testing.T) {
validTaggingBody, err := xml.Marshal(s3response.Tagging{
TagSet: s3response.TagSet{
Tags: []s3response.Tag{
{
Key: "key",
Value: "val",
},
},
},
})
assert.NoError(t, err)
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 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,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
Status: http.StatusNoContent,
},
},
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
},
},
{
name: "successful response",
input: testInput{
locals: defaultLocals,
body: validTaggingBody,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
Status: http.StatusNoContent,
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
be := &BackendMock{
PutBucketTaggingFunc: func(contextMoqParam context.Context, bucket 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.PutBucketTagging,
tt.output.response,
tt.output.err,
ctxInputs{
locals: tt.input.locals,
body: tt.input.body,
})
})
}
}
func TestS3ApiController_PutBucketOwnershipControls(t *testing.T) {
validOwnershipBody, err := xml.Marshal(
s3response.OwnershipControls{
Rules: []types.OwnershipControlsRule{
{ObjectOwnership: types.ObjectOwnershipBucketOwnerEnforced},
},
})
assert.NoError(t, err)
invalidRuleCountBody, err := xml.Marshal(
s3response.OwnershipControls{
Rules: []types.OwnershipControlsRule{
{ObjectOwnership: types.ObjectOwnershipBucketOwnerEnforced},
{ObjectOwnership: types.ObjectOwnershipBucketOwnerPreferred},
},
},
)
assert.NoError(t, err)
emptyRulesBody, err := xml.Marshal(
s3response.OwnershipControls{},
)
assert.NoError(t, err)
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 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 rules count",
input: testInput{
locals: defaultLocals,
body: invalidRuleCountBody,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: "root"},
},
err: s3err.GetAPIError(s3err.ErrMalformedXML),
},
},
{
name: "empty rules count",
input: testInput{
locals: defaultLocals,
body: emptyRulesBody,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: "root"},
},
err: s3err.GetAPIError(s3err.ErrMalformedXML),
},
},
{
name: "backend error",
input: testInput{
locals: defaultLocals,
body: validOwnershipBody,
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: "root"},
},
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
},
},
{
name: "success",
input: testInput{
locals: defaultLocals,
body: validOwnershipBody,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
be := &BackendMock{
PutBucketOwnershipControlsFunc: func(contextMoqParam context.Context, bucket string, ownership types.ObjectOwnership) 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.PutBucketOwnershipControls, tt.output.response, tt.output.err, ctxInputs{
locals: tt.input.locals,
body: tt.input.body,
})
})
}
}
func TestS3ApiController_PutBucketVersioning(t *testing.T) {
validVersioningBody, err := xml.Marshal(
types.VersioningConfiguration{
Status: types.BucketVersioningStatusEnabled,
},
)
assert.NoError(t, err)
invalidVersioningStatusBody, err := xml.Marshal(
types.VersioningConfiguration{
Status: types.BucketVersioningStatus("invalid_status"),
},
)
assert.NoError(t, err)
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 request body",
input: testInput{
locals: defaultLocals,
body: []byte("invalid_body"),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: "root"},
},
err: s3err.GetAPIError(s3err.ErrInvalidRequest),
},
},
{
name: "invalid rules count",
input: testInput{
locals: defaultLocals,
body: invalidVersioningStatusBody,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: "root"},
},
err: s3err.GetAPIError(s3err.ErrMalformedXML),
},
},
{
name: "backend error",
input: testInput{
locals: defaultLocals,
body: validVersioningBody,
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: "root"},
},
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
},
},
{
name: "success",
input: testInput{
locals: defaultLocals,
body: validVersioningBody,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
be := &BackendMock{
PutBucketVersioningFunc: func(contextMoqParam context.Context, bucket string, status types.BucketVersioningStatus) 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.PutBucketVersioning, tt.output.response, tt.output.err, ctxInputs{
locals: tt.input.locals,
body: tt.input.body,
})
})
}
}
func TestS3ApiController_PutObjectLockConfiguration(t *testing.T) {
validLockBody, err := xml.Marshal(
types.ObjectLockConfiguration{
ObjectLockEnabled: types.ObjectLockEnabledEnabled,
},
)
assert.NoError(t, err)
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 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 error",
input: testInput{
locals: defaultLocals,
body: validLockBody,
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: "root"},
},
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
},
},
{
name: "success",
input: testInput{
locals: defaultLocals,
body: validLockBody,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
be := &BackendMock{
PutObjectLockConfigurationFunc: func(contextMoqParam context.Context, bucket string, config []byte) 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.PutObjectLockConfiguration, tt.output.response, tt.output.err, ctxInputs{
locals: tt.input.locals,
body: tt.input.body,
})
})
}
}
func TestS3ApiController_PutBucketCors(t *testing.T) {
validBody, err := xml.Marshal(auth.CORSConfiguration{
Rules: []auth.CORSRule{
{
AllowedOrigins: []auth.CORSOrigin{"*"},
AllowedMethods: []auth.CORSHTTPMethod{http.MethodPost},
},
},
})
assert.NoError(t, err)
invalidCors, err := xml.Marshal(auth.CORSConfiguration{
Rules: []auth.CORSRule{
{
AllowedOrigins: []auth.CORSOrigin{"origin"},
AllowedMethods: []auth.CORSHTTPMethod{"invalid_method"},
},
},
})
assert.NoError(t, err)
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 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 cors config",
input: testInput{
locals: defaultLocals,
body: invalidCors,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: "root"},
},
err: s3err.GetUnsopportedCORSMethodErr("invalid_method"),
},
},
{
name: "backend error",
input: testInput{
locals: defaultLocals,
beErr: s3err.GetAPIError(s3err.ErrNotImplemented),
body: validBody,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: "root"},
},
err: s3err.GetAPIError(s3err.ErrNotImplemented),
},
},
{
name: "success",
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{
PutBucketCorsFunc: func(contextMoqParam context.Context, bucket string, cors []byte) 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.PutBucketCors, tt.output.response, tt.output.err, ctxInputs{
locals: tt.input.locals,
body: tt.input.body,
headers: tt.input.headers,
})
})
}
}
func TestS3ApiController_PutBucketPolicy(t *testing.T) {
validPolicyDocument :=
`{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::bucket/*"
}
]
}`
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 policy document",
input: testInput{
locals: defaultLocals,
body: []byte("invalid_body"),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: "root"},
},
err: s3err.APIError{
Code: "MalformedPolicy",
Description: "Policies must be valid JSON and the first byte must be '{'",
HTTPStatusCode: http.StatusBadRequest,
},
},
},
{
name: "backend error",
input: testInput{
locals: defaultLocals,
body: []byte(validPolicyDocument),
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
Status: http.StatusNoContent,
},
},
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
},
},
{
name: "success",
input: testInput{
locals: defaultLocals,
body: []byte(validPolicyDocument),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
Status: http.StatusNoContent,
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
be := &BackendMock{
PutBucketPolicyFunc: func(contextMoqParam context.Context, bucket string, policy []byte) 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.PutBucketPolicy, tt.output.response, tt.output.err, ctxInputs{
locals: tt.input.locals,
body: tt.input.body,
})
})
}
}
func TestS3ApiController_CreateBucket(t *testing.T) {
adminAcc := auth.Account{
Access: "root",
Role: auth.RoleAdmin,
}
userAcc := auth.Account{
Access: "user",
Role: auth.RoleUser,
}
userPlusAcc := auth.Account{
Access: "userplus",
Role: auth.RoleUserPlus,
}
invLocConstBody, err := xml.Marshal(s3response.CreateBucketConfiguration{
LocationConstraint: utils.GetStringPtr("us-west-1"),
})
assert.NoError(t, err)
euLocConstBody, err := xml.Marshal(s3response.CreateBucketConfiguration{
LocationConstraint: utils.GetStringPtr("eu-central-1"),
})
assert.NoError(t, err)
tests := []struct {
name string
input testInput
output testOutput
}{
{
name: "access denied",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyAccount: userAcc,
utils.ContextKeyIsRoot: false,
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{},
},
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "invalid bucket name",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyAccount: adminAcc,
},
bucket: "invalid_bucket_name",
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: adminAcc.Access,
},
},
err: s3err.GetBucketErr(s3err.ErrInvalidBucketName, "invalid_bucket_name"),
},
},
{
name: "malformed body",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyAccount: adminAcc,
},
body: []byte("invalid_body"),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: adminAcc.Access},
},
err: s3err.GetAPIError(s3err.ErrMalformedXML),
},
},
{
name: "invalid canned acl",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyAccount: adminAcc,
},
headers: map[string]string{
"x-amz-acl": "invalid_acl",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: adminAcc.Access},
},
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgCannedAcl, "invalid_acl"),
},
},
{
name: "invalid location constraint",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyAccount: adminAcc,
utils.ContextKeyRegion: "us-east-1",
},
body: invLocConstBody,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: adminAcc.Access},
},
err: s3err.GetInvalidLocationConstraintErr("us-west-1"),
},
},
{
name: "illegal location constraint",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyAccount: adminAcc,
utils.ContextKeyRegion: "us-west-1",
},
body: euLocConstBody,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: adminAcc.Access},
},
err: s3err.GetIllegalLocationConstraintErr("eu-central-1"),
},
},
{
name: "missing location constraint",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyAccount: adminAcc,
utils.ContextKeyRegion: "us-west-1",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: adminAcc.Access},
},
err: s3err.GetIllegalLocationConstraintErr(""),
},
},
{
name: "matching location constraint",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyAccount: adminAcc,
utils.ContextKeyRegion: "us-west-1",
},
bucket: "my-bucket",
body: invLocConstBody,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: adminAcc.Access,
},
Headers: map[string]*string{
"Location": utils.GetStringPtr("/my-bucket"),
"x-amz-bucket-arn": utils.GetStringPtr("arn:aws:s3:::my-bucket"),
},
},
},
},
{
name: "invalid ownership",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyAccount: adminAcc,
},
headers: map[string]string{
"X-Amz-Object-Ownership": "invalid_ownership",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: adminAcc.Access,
},
},
err: s3err.GetInvalidArgObjectOwnership("invalid_ownership"),
},
},
{
name: "invalid ownership + acl",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyAccount: adminAcc,
},
headers: map[string]string{
"X-Amz-Object-Ownership": string(types.ObjectOwnershipBucketOwnerEnforced),
"X-Amz-Acl": string(types.BucketCannedACLPublicRead),
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: adminAcc.Access},
},
err: s3err.GetAPIError(s3err.ErrInvalidBucketAclWithObjectOwnership),
},
},
{
name: "both grants and canned acl",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyAccount: adminAcc,
},
headers: map[string]string{
"X-Amz-Acl": string(types.BucketCannedACLPublicRead),
"X-Amz-Grant-Read": userAcc.Access,
"X-Amz-Object-Ownership": string(types.ObjectOwnershipBucketOwnerPreferred),
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: adminAcc.Access},
},
err: s3err.GetAPIError(s3err.ErrBothCannedAndHeaderGrants),
},
},
{
name: "fail to update the acl",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyAccount: adminAcc,
},
headers: map[string]string{
"X-Amz-Grant-Read": userAcc.Access,
"X-Amz-Object-Ownership": string(types.ObjectOwnershipBucketOwnerPreferred),
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: adminAcc.Access},
},
err: fmt.Errorf("accounts does not exist: %s", userAcc.Access),
},
},
{
name: "backend error",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyAccount: adminAcc,
},
beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: adminAcc.Access},
},
err: s3err.GetAPIError(s3err.ErrNoSuchBucket),
},
},
{
name: "success",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyAccount: adminAcc,
},
bucket: "my-bucket",
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: adminAcc.Access,
},
Headers: map[string]*string{
"Location": utils.GetStringPtr("/my-bucket"),
"x-amz-bucket-arn": utils.GetStringPtr("arn:aws:s3:::my-bucket"),
},
},
},
},
{
name: "userplus role can create bucket",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyAccount: userPlusAcc,
},
bucket: "my-bucket",
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: userPlusAcc.Access,
},
Headers: map[string]*string{
"Location": utils.GetStringPtr("/my-bucket"),
"x-amz-bucket-arn": utils.GetStringPtr("arn:aws:s3:::my-bucket"),
},
},
},
},
{
name: "root bypasses role check",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyAccount: userAcc,
utils.ContextKeyIsRoot: true,
},
bucket: "my-bucket",
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: userAcc.Access,
},
Headers: map[string]*string{
"Location": utils.GetStringPtr("/my-bucket"),
"x-amz-bucket-arn": utils.GetStringPtr("arn:aws:s3:::my-bucket"),
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
be := &BackendMock{
CreateBucketFunc: func(contextMoqParam context.Context, createBucketInput *s3.CreateBucketInput, defaultACL []byte) error {
return tt.input.beErr
},
}
ctrl := S3ApiController{
be: be,
iam: auth.NewIAMServiceSingle(adminAcc),
}
testController(t, ctrl.CreateBucket, tt.output.response, tt.output.err,
ctxInputs{
locals: tt.input.locals,
body: tt.input.body,
bucket: tt.input.bucket,
headers: tt.input.headers,
})
})
}
}
func TestS3ApiController_PutBucketAcl(t *testing.T) {
invalidBody, err := xml.Marshal(auth.AccessControlPolicy{
Owner: &types.Owner{
ID: utils.GetStringPtr("root"),
},
AccessControlList: auth.AccessControlList{
Grants: []auth.Grant{
{
Permission: auth.Permission("invalid_permission"),
},
},
},
})
assert.NoError(t, err)
incorrectOwnerBody, err := xml.Marshal(auth.AccessControlPolicy{
Owner: &types.Owner{
ID: utils.GetStringPtr("user"),
},
AccessControlList: auth.AccessControlList{},
})
assert.NoError(t, err)
validAccessControlPolicy, err := xml.Marshal(auth.AccessControlPolicy{
Owner: &types.Owner{
ID: utils.GetStringPtr("root"),
},
AccessControlList: auth.AccessControlList{},
})
assert.NoError(t, err)
tests := []struct {
name string
input testInput
output testOutput
}{
{
name: "access denied",
input: testInput{
locals: accessDeniedLocals,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "fails to get bucket ownership",
input: testInput{
locals: defaultLocals,
extraMockErr: s3err.GetAPIError(s3err.ErrInternalError),
extraMockResp: types.ObjectOwnership(""),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrInternalError),
},
},
{
name: "acl not supported",
input: testInput{
locals: defaultLocals,
extraMockResp: types.ObjectOwnershipBucketOwnerEnforced,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrAclNotSupported),
},
},
{
name: "invalid request body",
input: testInput{
locals: defaultLocals,
extraMockResp: types.ObjectOwnershipBucketOwnerPreferred,
body: []byte("invalid_body"),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrMalformedACL),
},
},
{
name: "invalid access control policy",
input: testInput{
locals: defaultLocals,
extraMockResp: types.ObjectOwnershipBucketOwnerPreferred,
body: invalidBody,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrMalformedACL),
},
},
{
name: "incorrect owner id",
input: testInput{
locals: defaultLocals,
extraMockResp: types.ObjectOwnershipBucketOwnerPreferred,
body: incorrectOwnerBody,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.APIError{
Code: "InvalidArgument",
Description: "Invalid id",
HTTPStatusCode: http.StatusBadRequest,
},
},
},
{
name: "both access control policy and grants",
input: testInput{
body: validAccessControlPolicy,
extraMockResp: types.ObjectOwnershipBucketOwnerPreferred,
headers: map[string]string{
"X-Amz-Acl": "public-read",
},
locals: defaultLocals,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrUnexpectedContent),
},
},
{
name: "access control policy success",
input: testInput{
body: validAccessControlPolicy,
extraMockResp: types.ObjectOwnershipBucketOwnerPreferred,
locals: defaultLocals,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
},
},
{
name: "invalid canned acl",
input: testInput{
extraMockResp: types.ObjectOwnershipBucketOwnerPreferred,
headers: map[string]string{
"X-Amz-Acl": "invalid_acl",
},
locals: defaultLocals,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgCannedAcl, "invalid_acl"),
},
},
{
name: "both canned acl and grants",
input: testInput{
extraMockResp: types.ObjectOwnershipBucketOwnerPreferred,
headers: map[string]string{
"X-Amz-Acl": "public-read",
"X-Amz-Grant-Read": "grt1",
},
locals: defaultLocals,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrBothCannedAndHeaderGrants),
},
},
{
name: "canned acl success",
input: testInput{
extraMockResp: types.ObjectOwnershipBucketOwnerPreferred,
headers: map[string]string{
"X-Amz-Acl": "public-read",
},
locals: defaultLocals,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
},
},
{
name: "grants update acl fails",
input: testInput{
extraMockResp: types.ObjectOwnershipBucketOwnerPreferred,
headers: map[string]string{
"X-Amz-Grant-Read": "grt1",
},
locals: defaultLocals,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: errors.New("accounts does not exist: grt1"),
},
},
{
name: "no option provided",
input: testInput{
extraMockResp: types.ObjectOwnershipBucketOwnerPreferred,
locals: defaultLocals,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrMissingSecurityHeader),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
be := &BackendMock{
PutBucketAclFunc: func(contextMoqParam context.Context, bucket string, data []byte) error {
return tt.input.beErr
},
GetBucketOwnershipControlsFunc: func(contextMoqParam context.Context, bucket string) (types.ObjectOwnership, error) {
return tt.input.extraMockResp.(types.ObjectOwnership), tt.input.extraMockErr
},
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
},
}
ctrl := S3ApiController{
be: be,
iam: auth.NewIAMServiceSingle(
auth.Account{
Access: "root",
}),
}
testController(t, ctrl.PutBucketAcl, tt.output.response, tt.output.err,
ctxInputs{
locals: tt.input.locals,
body: tt.input.body,
bucket: tt.input.bucket,
headers: tt.input.headers,
})
})
}
}
func TestS3ApiController_PutBucketWebsite(t *testing.T) {
validBody, err := xml.Marshal(s3response.WebsiteConfiguration{
IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
})
assert.NoError(t, err)
// The payload from https://github.com/versity/versitygw/issues/2260
emptyBody := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<WebsiteConfiguration xmlns="https://s3.amazonaws.com/doc/2006-03-01/"></WebsiteConfiguration>`)
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: "empty website configuration",
input: testInput{
locals: defaultLocals,
body: emptyBody,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: "root"},
},
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgMissingIndexDocumentSuffix, "null"),
},
},
{
name: "malformed xml",
input: testInput{
locals: defaultLocals,
body: []byte("<WebsiteConfiguration><IndexDocument>"),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: "root"},
},
err: s3err.GetAPIError(s3err.ErrMalformedXML),
},
},
{
name: "backend 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: "success",
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{
PutBucketWebsiteFunc: func(contextMoqParam context.Context, bucket string, website []byte) 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.PutBucketWebsite, tt.output.response, tt.output.err, ctxInputs{
locals: tt.input.locals,
body: tt.input.body,
})
})
}
}