diff --git a/auth/bucket_policy.go b/auth/bucket_policy.go index 4f817199..6d6d1495 100644 --- a/auth/bucket_policy.go +++ b/auth/bucket_policy.go @@ -17,6 +17,7 @@ package auth import ( "encoding/json" "errors" + "fmt" "net/http" "github.com/versity/versitygw/s3err" @@ -194,7 +195,7 @@ func ValidatePolicyDocument(policyBin []byte, bucket string, iam IAMService) err func VerifyBucketPolicy(policy []byte, access, bucket, object string, action Action) error { var bucketPolicy BucketPolicy if err := json.Unmarshal(policy, &bucketPolicy); err != nil { - return err + return fmt.Errorf("failed to parse the bucket policy: %w", err) } resource := bucket diff --git a/s3api/controllers/base_test.go b/s3api/controllers/base_test.go index 4f0173d6..5cabf374 100644 --- a/s3api/controllers/base_test.go +++ b/s3api/controllers/base_test.go @@ -54,15 +54,15 @@ var ( ) type testInput struct { - bucket string - object string - body []byte - locals map[utils.ContextKey]any - headers map[string]string - queries map[string]string - beRes any - beErr error - extraMockErr error + bucket string + body []byte + locals map[utils.ContextKey]any + headers map[string]string + queries map[string]string + beRes any + beErr error + extraMockErr error + extraMockResp any } type testOutput struct { diff --git a/s3api/controllers/object-put.go b/s3api/controllers/object-put.go index 453f624f..124db9a5 100644 --- a/s3api/controllers/object-put.go +++ b/s3api/controllers/object-put.go @@ -42,16 +42,7 @@ func (c S3ApiController) PutObjectTagging(ctx *fiber.Ctx) (*Response, error) { IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) - tagging, err := utils.ParseTagging(ctx.Body(), utils.TagLimitObject) - if err != nil { - return &Response{ - MetaOpts: &MetaOptions{ - BucketOwner: parsedAcl.Owner, - }, - }, err - } - - err = auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{ + err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{ Readonly: c.readonly, Acl: parsedAcl, AclPermission: auth.PermissionWrite, @@ -70,6 +61,15 @@ func (c S3ApiController) PutObjectTagging(ctx *fiber.Ctx) (*Response, error) { }, err } + tagging, err := utils.ParseTagging(ctx.Body(), utils.TagLimitObject) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + err = c.be.PutObjectTagging(ctx.Context(), bucket, key, tagging) return &Response{ MetaOpts: &MetaOptions{ @@ -145,6 +145,24 @@ func (c S3ApiController) PutObjectLegalHold(ctx *fiber.Ctx) (*Response, error) { IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx) parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) + if err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{ + Readonly: c.readonly, + Acl: parsedAcl, + AclPermission: auth.PermissionWrite, + IsRoot: isRoot, + Acc: acct, + Bucket: bucket, + Object: key, + Action: auth.PutObjectLegalHoldAction, + IsBucketPublic: IsBucketPublic, + }); err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + var legalHold types.ObjectLockLegalHold if err := xml.Unmarshal(ctx.Body(), &legalHold); err != nil { debuglogger.Logf("failed to parse request body: %v", err) @@ -164,24 +182,6 @@ func (c S3ApiController) PutObjectLegalHold(ctx *fiber.Ctx) (*Response, error) { }, s3err.GetAPIError(s3err.ErrMalformedXML) } - if err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{ - Readonly: c.readonly, - Acl: parsedAcl, - AclPermission: auth.PermissionWrite, - IsRoot: isRoot, - Acc: acct, - Bucket: bucket, - Object: key, - Action: auth.PutObjectLegalHoldAction, - IsBucketPublic: IsBucketPublic, - }); err != nil { - return &Response{ - MetaOpts: &MetaOptions{ - BucketOwner: parsedAcl.Owner, - }, - }, err - } - err := c.be.PutObjectLegalHold(ctx.Context(), bucket, key, versionId, legalHold.Status == types.ObjectLockLegalHoldStatusOn) return &Response{ MetaOpts: &MetaOptions{ @@ -212,15 +212,6 @@ func (c S3ApiController) UploadPart(ctx *fiber.Ctx) (*Response, error) { contentLengthStr = decodedLength } - if partNumber < 1 || partNumber > 10000 { - debuglogger.Logf("invalid part number: %d", partNumber) - return &Response{ - MetaOpts: &MetaOptions{ - BucketOwner: parsedAcl.Owner, - }, - }, s3err.GetAPIError(s3err.ErrInvalidPartNumber) - } - err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{ Readonly: c.readonly, @@ -241,6 +232,15 @@ func (c S3ApiController) UploadPart(ctx *fiber.Ctx) (*Response, error) { }, err } + if partNumber < 1 || partNumber > 10000 { + debuglogger.Logf("invalid part number: %d", partNumber) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, s3err.GetAPIError(s3err.ErrInvalidPartNumber) + } + contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64) if err != nil { debuglogger.Logf("error parsing content length %q: %v", contentLengthStr, err) @@ -329,15 +329,6 @@ func (c S3ApiController) UploadPartCopy(ctx *fiber.Ctx) (*Response, error) { }, s3err.GetAPIError(s3err.ErrInvalidCopySource) } - if partNumber < 1 || partNumber > 10000 { - debuglogger.Logf("invalid part number: %d", partNumber) - return &Response{ - MetaOpts: &MetaOptions{ - BucketOwner: parsedAcl.Owner, - }, - }, s3err.GetAPIError(s3err.ErrInvalidPartNumber) - } - err = auth.VerifyObjectCopyAccess(ctx.Context(), c.be, copySource, auth.AccessOptions{ Acl: parsedAcl, @@ -357,6 +348,15 @@ func (c S3ApiController) UploadPartCopy(ctx *fiber.Ctx) (*Response, error) { }, err } + if partNumber < 1 || partNumber > 10000 { + debuglogger.Logf("invalid part number: %d", partNumber) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, s3err.GetAPIError(s3err.ErrInvalidPartNumber) + } + resp, err := c.be.UploadPartCopy(ctx.Context(), &s3.UploadPartCopyInput{ Bucket: &bucket, @@ -390,7 +390,6 @@ func (c S3ApiController) PutObjectAcl(ctx *fiber.Ctx) (*Response, error) { grantReadACP := ctx.Get("X-Amz-Grant-Read-Acp") granWrite := ctx.Get("X-Amz-Grant-Write") grantWriteACP := ctx.Get("X-Amz-Grant-Write-Acp") - grants := grantFullControl + grantRead + grantReadACP + granWrite + grantWriteACP // context locals acct := utils.ContextKeyAccount.Get(ctx).(auth.Account) isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) @@ -415,95 +414,16 @@ func (c S3ApiController) PutObjectAcl(ctx *fiber.Ctx) (*Response, error) { }, err } - var input *s3.PutObjectAclInput - if len(ctx.Body()) > 0 { - if grants+acl != "" { - debuglogger.Logf("invalid request: %q (grants) %q (acl)", grants, acl) - return &Response{ - MetaOpts: &MetaOptions{ - BucketOwner: parsedAcl.Owner, - }, - }, s3err.GetAPIError(s3err.ErrInvalidRequest) - } - - var accessControlPolicy auth.AccessControlPolicy - err := xml.Unmarshal(ctx.Body(), &accessControlPolicy) - if err != nil { - debuglogger.Logf("error unmarshalling access control policy: %v", err) - return &Response{ - MetaOpts: &MetaOptions{ - BucketOwner: parsedAcl.Owner, - }, - }, s3err.GetAPIError(s3err.ErrInvalidRequest) - } - - //TODO: This part will be changed when object acls are implemented - - grants := []types.Grant{} - for _, grt := range accessControlPolicy.AccessControlList.Grants { - grants = append(grants, types.Grant{ - Grantee: &types.Grantee{ - ID: &grt.Grantee.ID, - Type: grt.Grantee.Type, - }, - Permission: types.Permission(grt.Permission), - }) - } - - input = &s3.PutObjectAclInput{ - Bucket: &bucket, - Key: &key, - ACL: "", - AccessControlPolicy: &types.AccessControlPolicy{ - Owner: accessControlPolicy.Owner, - Grants: grants, - }, - } - } - if acl != "" { - if acl != "private" && acl != "public-read" && acl != "public-read-write" { - debuglogger.Logf("invalid acl: %q", acl) - return &Response{ - MetaOpts: &MetaOptions{ - BucketOwner: parsedAcl.Owner, - }, - }, s3err.GetAPIError(s3err.ErrInvalidRequest) - } - if len(ctx.Body()) > 0 || grants != "" { - debuglogger.Logf("invalid request: %q (grants) %q (acl) %v (body len)", grants, acl, len(ctx.Body())) - return &Response{ - MetaOpts: &MetaOptions{ - BucketOwner: parsedAcl.Owner, - }, - }, s3err.GetAPIError(s3err.ErrInvalidRequest) - } - - input = &s3.PutObjectAclInput{ - Bucket: &bucket, - Key: &key, - ACL: types.ObjectCannedACL(acl), - AccessControlPolicy: &types.AccessControlPolicy{ - Owner: &types.Owner{ID: &parsedAcl.Owner}, - }, - } - } - if grants != "" { - input = &s3.PutObjectAclInput{ - Bucket: &bucket, - Key: &key, - GrantFullControl: &grantFullControl, - GrantRead: &grantRead, - GrantReadACP: &grantReadACP, - GrantWrite: &granWrite, - GrantWriteACP: &grantWriteACP, - AccessControlPolicy: &types.AccessControlPolicy{ - Owner: &types.Owner{ID: &parsedAcl.Owner}, - }, - ACL: "", - } - } - - err = c.be.PutObjectAcl(ctx.Context(), input) + err = c.be.PutObjectAcl(ctx.Context(), &s3.PutObjectAclInput{ + Bucket: &bucket, + Key: &key, + GrantFullControl: &grantFullControl, + GrantRead: &grantRead, + GrantWrite: &granWrite, + ACL: types.ObjectCannedACL(acl), + GrantReadACP: &grantReadACP, + GrantWriteACP: &grantWriteACP, + }) return &Response{ MetaOpts: &MetaOptions{ BucketOwner: parsedAcl.Owner, diff --git a/s3api/controllers/object-put_test.go b/s3api/controllers/object-put_test.go new file mode 100644 index 00000000..516f02dc --- /dev/null +++ b/s3api/controllers/object-put_test.go @@ -0,0 +1,1243 @@ +// 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/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.TaggingInput{ + 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", + EventName: s3event.EventObjectTaggingPut, + }, + }, + err: s3err.GetAPIError(s3err.ErrNoSuchBucket), + }, + }, + { + name: "successful response", + input: testInput{ + locals: defaultLocals, + body: validTaggingBody, + }, + output: testOutput{ + response: &Response{ + 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 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, + }) + }) + } +} + +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: "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: "backend returns error", + input: testInput{ + locals: defaultLocals, + beErr: s3err.GetAPIError(s3err.ErrNoSuchBucket), + body: validRetentionBody, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrNoSuchBucket), + }, + }, + { + name: "success bypass GetBucketPolicy fails", + input: testInput{ + locals: defaultLocals, + body: validRetentionBody, + extraMockErr: s3err.GetAPIError(s3err.ErrAccessDenied), + headers: map[string]string{ + "X-Amz-Bypass-Governance-Retention": "true", + }, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + }, + }, + { + name: "success bypass VerifyBucketPolicy fails", + input: testInput{ + locals: defaultLocals, + body: validRetentionBody, + extraMockResp: []byte("invalid_policy"), + headers: map[string]string{ + "X-Amz-Bypass-Governance-Retention": "true", + }, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + }, + }, + { + name: "successful response", + input: testInput{ + locals: defaultLocals, + body: validRetentionBody, + }, + 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, bypass bool, retention []byte) error { + return tt.input.beErr + }, + GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) { + if tt.input.extraMockResp == nil { + return nil, tt.input.extraMockErr + } else { + return tt.input.extraMockResp.([]byte), 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, + }) + }) + } +} + +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: "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, + }) + }) + } +} + +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.GetAPIError(s3err.ErrInvalidPartNumber), + }, + }, + { + 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, + }, + 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, + } + + 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, + }, + 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.GetAPIError(s3err.ErrInvalidCopySource), + }, + }, + { + 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.GetAPIError(s3err.ErrInvalidPartNumber), + }, + }, + { + 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, + } + + testController( + t, + ctrl.UploadPartCopy, + tt.output.response, + tt.output.err, + ctxInputs{ + locals: tt.input.locals, + headers: tt.input.headers, + queries: tt.input.queries, + }) + }) + } +} + +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.ErrAccessDenied) + }, + } + + 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, + }, + 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", + }, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrInvalidCopySource), + }, + }, + { + name: "invalid copy source modified since", + input: testInput{ + locals: defaultLocals, + headers: map[string]string{ + "X-Amz-Copy-Source": "bucket/object", + "X-Amz-Copy-Source-If-Modified-Since": "invalid_date", + }, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrInvalidCopySource), + }, + }, + { + name: "invalid copy source unmodified since", + input: testInput{ + locals: defaultLocals, + headers: map[string]string{ + "X-Amz-Copy-Source": "bucket/object", + "X-Amz-Copy-Source-If-Unmodified-Since": "invalid_date", + }, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrInvalidCopySource), + }, + }, + { + 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.GetAPIError(s3err.ErrInvalidMetadataDirective), + }, + }, + { + 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.GetAPIError(s3err.ErrInvalidTaggingDirective), + }, + }, + { + 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.GetAPIError(s3err.ErrObjectLockInvalidHeaders), + }, + }, + { + 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", + }, + }, + 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"), + }, + }, + }, + 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.ErrAccessDenied) + }, + } + + ctrl := S3ApiController{ + be: be, + } + + testController( + t, + ctrl.CopyObject, + tt.output.response, + tt.output.err, + ctxInputs{ + locals: tt.input.locals, + headers: tt.input.headers, + }) + }) + } +} + +func TestS3ApiController_PutObject(t *testing.T) { + str := "" + emptyStringPtr := &str + + 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 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.GetAPIError(s3err.ErrObjectLockInvalidHeaders), + }, + }, + { + 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-type": nil, + "x-amz-version-id": emptyStringPtr, + }, + 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"), + ChecksumType: types.ChecksumTypeComposite, + VersionID: "versionId", + }, + }, + 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-type": utils.GetStringPtr(string(types.ChecksumTypeComposite)), + "x-amz-version-id": utils.GetStringPtr("versionId"), + }, + 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 + }, + } + + 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, + }) + }) + } +}