mirror of
https://github.com/versity/versitygw.git
synced 2026-09-22 16:04:15 +00:00
Browser-based POST uploads (POST /{bucket}) read Content-Type straight out
of the form fields, so a form without a content-type field stored the object
with an empty Content-Type. On read, fasthttp substitutes its own default,
so the object came back as "text/plain; charset=utf-8" rather than just
missing a type.
Fall back to defaultContentType ("binary/octet-stream") when the form field
is absent or empty, matching PutObject, CopyObject and CreateMultipartUpload,
as well as AWS S3 and Ceph RGW.
752 lines
23 KiB
Go
752 lines
23 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"
|
|
"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)
|
|
|
|
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: "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: map[string]string{
|
|
"key": "key",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
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: "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"},
|
|
}),
|
|
"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",
|
|
},
|
|
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, 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)
|
|
},
|
|
}
|
|
|
|
ctrl := S3ApiController{
|
|
be: be,
|
|
}
|
|
|
|
testController(
|
|
t,
|
|
ctrl.POSTObject,
|
|
tt.output.response,
|
|
tt.output.err,
|
|
ctxInputs{
|
|
locals: tt.input.locals,
|
|
},
|
|
)
|
|
})
|
|
}
|
|
}
|