package handler import ( "bytes" "crypto/md5" "crypto/rand" "encoding/base64" "encoding/xml" "io" "net/http" "net/http/httptest" "strconv" "testing" "time" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/data" apierr "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/errors" "github.com/mr-tron/base58" "github.com/stretchr/testify/require" ) func TestPutBucketLifecycleConfiguration(t *testing.T) { hc := prepareHandlerContextWithMinCache(t) bktName := "bucket-lifecycle" createBucket(hc, bktName) for _, tc := range []struct { name string body *data.LifecycleConfiguration errorCode apierr.ErrorCode }{ { name: "correct configuration", body: &data.LifecycleConfiguration{ Rules: []data.LifecycleRule{ { ID: "rule-1", Status: data.LifecycleStatusEnabled, Expiration: &data.LifecycleExpiration{ Days: ptr(21), Date: time.Now().Format("2006-01-02T15:04:05.000Z"), }, Filter: &data.LifecycleRuleFilter{ And: &data.LifecycleRuleAndOperator{ Prefix: "prefix/", Tags: []data.Tag{{Key: "key", Value: "value"}, {Key: "tag", Value: ""}}, ObjectSizeGreaterThan: ptr(uint64(100)), }, }, }, { ID: "rule-2", Status: data.LifecycleStatusEnabled, AbortIncompleteMultipartUpload: &data.AbortIncompleteMultipartUpload{ DaysAfterInitiation: ptr(14), }, Expiration: &data.LifecycleExpiration{ ExpiredObjectDeleteMarker: ptr(true), }, Filter: &data.LifecycleRuleFilter{ ObjectSizeLessThan: ptr(uint64(100)), }, NonCurrentVersionExpiration: &data.NonCurrentVersionExpiration{ NewerNonCurrentVersions: ptr(1), NonCurrentDays: ptr(21), }, }, }, }, }, { name: "too many rules", body: func() *data.LifecycleConfiguration { lifecycle := new(data.LifecycleConfiguration) for i := 0; i <= maxRules; i++ { lifecycle.Rules = append(lifecycle.Rules, data.LifecycleRule{ ID: "Rule" + strconv.Itoa(i), }) } return lifecycle }(), errorCode: apierr.ErrInvalidRequest, }, { name: "duplicate rule ID", body: &data.LifecycleConfiguration{ Rules: []data.LifecycleRule{ { ID: "Rule", Status: data.LifecycleStatusEnabled, Expiration: &data.LifecycleExpiration{ Days: ptr(21), }, }, { ID: "Rule", Status: data.LifecycleStatusEnabled, Expiration: &data.LifecycleExpiration{ Days: ptr(21), }, }, }, }, errorCode: apierr.ErrInvalidArgument, }, { name: "too long rule ID", body: func() *data.LifecycleConfiguration { id := make([]byte, maxRuleIDLen+1) _, err := io.ReadFull(rand.Reader, id) require.NoError(t, err) return &data.LifecycleConfiguration{ Rules: []data.LifecycleRule{ { ID: base58.Encode(id)[:maxRuleIDLen+1], }, }, } }(), errorCode: apierr.ErrInvalidArgument, }, { name: "invalid status", body: &data.LifecycleConfiguration{ Rules: []data.LifecycleRule{ { Status: "invalid", }, }, }, errorCode: apierr.ErrMalformedXML, }, { name: "no actions", body: &data.LifecycleConfiguration{ Rules: []data.LifecycleRule{ { Status: data.LifecycleStatusEnabled, Filter: &data.LifecycleRuleFilter{ Prefix: "prefix/", }, }, }, }, errorCode: apierr.ErrInvalidRequest, }, { name: "invalid days after initiation", body: &data.LifecycleConfiguration{ Rules: []data.LifecycleRule{ { Status: data.LifecycleStatusEnabled, AbortIncompleteMultipartUpload: &data.AbortIncompleteMultipartUpload{ DaysAfterInitiation: ptr(0), }, }, }, }, errorCode: apierr.ErrInvalidArgument, }, { name: "invalid expired object delete marker declaration", body: &data.LifecycleConfiguration{ Rules: []data.LifecycleRule{ { Status: data.LifecycleStatusEnabled, Expiration: &data.LifecycleExpiration{ Days: ptr(21), ExpiredObjectDeleteMarker: ptr(false), }, }, }, }, errorCode: apierr.ErrMalformedXML, }, { name: "invalid expiration days", body: &data.LifecycleConfiguration{ Rules: []data.LifecycleRule{ { Status: data.LifecycleStatusEnabled, Expiration: &data.LifecycleExpiration{ Days: ptr(0), }, }, }, }, errorCode: apierr.ErrInvalidArgument, }, { name: "invalid expiration date", body: &data.LifecycleConfiguration{ Rules: []data.LifecycleRule{ { Status: data.LifecycleStatusEnabled, Expiration: &data.LifecycleExpiration{ Date: "invalid", }, }, }, }, errorCode: apierr.ErrInvalidArgument, }, { name: "newer noncurrent versions is too small", body: &data.LifecycleConfiguration{ Rules: []data.LifecycleRule{ { Status: data.LifecycleStatusEnabled, NonCurrentVersionExpiration: &data.NonCurrentVersionExpiration{ NonCurrentDays: ptr(1), NewerNonCurrentVersions: ptr(0), }, }, }, }, errorCode: apierr.ErrInvalidArgument, }, { name: "newer noncurrent versions is too large", body: &data.LifecycleConfiguration{ Rules: []data.LifecycleRule{ { Status: data.LifecycleStatusEnabled, NonCurrentVersionExpiration: &data.NonCurrentVersionExpiration{ NonCurrentDays: ptr(1), NewerNonCurrentVersions: ptr(101), }, }, }, }, errorCode: apierr.ErrInvalidArgument, }, { name: "invalid noncurrent days", body: &data.LifecycleConfiguration{ Rules: []data.LifecycleRule{ { Status: data.LifecycleStatusEnabled, NonCurrentVersionExpiration: &data.NonCurrentVersionExpiration{ NonCurrentDays: ptr(0), }, }, }, }, errorCode: apierr.ErrInvalidArgument, }, { name: "more than one filter field", body: &data.LifecycleConfiguration{ Rules: []data.LifecycleRule{ { Status: data.LifecycleStatusEnabled, Expiration: &data.LifecycleExpiration{ Days: ptr(21), }, Filter: &data.LifecycleRuleFilter{ Prefix: "prefix/", ObjectSizeGreaterThan: ptr(uint64(100)), }, }, }, }, errorCode: apierr.ErrMalformedXML, }, { name: "invalid tag in filter", body: &data.LifecycleConfiguration{ Rules: []data.LifecycleRule{ { Status: data.LifecycleStatusEnabled, Expiration: &data.LifecycleExpiration{ Days: ptr(21), }, Filter: &data.LifecycleRuleFilter{ Tag: &data.Tag{}, }, }, }, }, errorCode: apierr.ErrInvalidTagKey, }, { name: "abort incomplete multipart upload with tag", body: &data.LifecycleConfiguration{ Rules: []data.LifecycleRule{ { Status: data.LifecycleStatusEnabled, AbortIncompleteMultipartUpload: &data.AbortIncompleteMultipartUpload{ DaysAfterInitiation: ptr(14), }, Filter: &data.LifecycleRuleFilter{ Tag: &data.Tag{Key: "key", Value: "value"}, }, }, }, }, errorCode: apierr.ErrInvalidRequest, }, { name: "expired object delete marker with tag", body: &data.LifecycleConfiguration{ Rules: []data.LifecycleRule{ { Status: data.LifecycleStatusEnabled, Expiration: &data.LifecycleExpiration{ ExpiredObjectDeleteMarker: ptr(true), }, Filter: &data.LifecycleRuleFilter{ And: &data.LifecycleRuleAndOperator{ Tags: []data.Tag{{Key: "key", Value: "value"}}, }, }, }, }, }, errorCode: apierr.ErrInvalidRequest, }, { name: "invalid size range", body: &data.LifecycleConfiguration{ Rules: []data.LifecycleRule{ { Status: data.LifecycleStatusEnabled, Expiration: &data.LifecycleExpiration{ Days: ptr(21), }, Filter: &data.LifecycleRuleFilter{ And: &data.LifecycleRuleAndOperator{ ObjectSizeGreaterThan: ptr(uint64(100)), ObjectSizeLessThan: ptr(uint64(100)), }, }, }, }, }, errorCode: apierr.ErrInvalidRequest, }, { name: "two prefixes", body: &data.LifecycleConfiguration{ Rules: []data.LifecycleRule{ { Status: data.LifecycleStatusEnabled, Expiration: &data.LifecycleExpiration{ Days: ptr(21), }, Filter: &data.LifecycleRuleFilter{ Prefix: "prefix-1/", }, Prefix: "prefix-2/", }, }, }, errorCode: apierr.ErrMalformedXML, }, { name: "newer noncurrent versions without noncurrent days", body: &data.LifecycleConfiguration{ Rules: []data.LifecycleRule{ { Status: data.LifecycleStatusEnabled, NonCurrentVersionExpiration: &data.NonCurrentVersionExpiration{ NewerNonCurrentVersions: ptr(10), }, }, }, }, errorCode: apierr.ErrMalformedXML, }, { name: "invalid maximum object size in filter", body: &data.LifecycleConfiguration{ Rules: []data.LifecycleRule{ { Status: data.LifecycleStatusEnabled, Expiration: &data.LifecycleExpiration{ Days: ptr(21), }, Filter: &data.LifecycleRuleFilter{ ObjectSizeLessThan: ptr(uint64(0)), }, }, }, }, errorCode: apierr.ErrInvalidRequest, }, { name: "invalid maximum object size in filter and", body: &data.LifecycleConfiguration{ Rules: []data.LifecycleRule{ { Status: data.LifecycleStatusEnabled, Expiration: &data.LifecycleExpiration{ Days: ptr(21), }, Filter: &data.LifecycleRuleFilter{ And: &data.LifecycleRuleAndOperator{ Prefix: "prefix/", ObjectSizeLessThan: ptr(uint64(0)), }, }, }, }, }, errorCode: apierr.ErrInvalidRequest, }, } { t.Run(tc.name, func(t *testing.T) { if tc.errorCode > 0 { putBucketLifecycleConfigurationErr(hc, bktName, tc.body, apierr.GetAPIError(tc.errorCode)) return } putBucketLifecycleConfiguration(hc, bktName, tc.body) cfg := getBucketLifecycleConfiguration(hc, bktName) require.Equal(t, tc.body.Rules, cfg.Rules) deleteBucketLifecycleConfiguration(hc, bktName) getBucketLifecycleConfigurationErr(hc, bktName, apierr.GetAPIError(apierr.ErrNoSuchLifecycleConfiguration)) }) } } func TestPutBucketLifecycleIDGeneration(t *testing.T) { hc := prepareHandlerContext(t) bktName := "bucket-lifecycle-id" createBucket(hc, bktName) lifecycle := &data.LifecycleConfiguration{ Rules: []data.LifecycleRule{ { Status: data.LifecycleStatusEnabled, Expiration: &data.LifecycleExpiration{ Days: ptr(21), }, }, { Status: data.LifecycleStatusEnabled, AbortIncompleteMultipartUpload: &data.AbortIncompleteMultipartUpload{ DaysAfterInitiation: ptr(14), }, }, }, } putBucketLifecycleConfiguration(hc, bktName, lifecycle) cfg := getBucketLifecycleConfiguration(hc, bktName) require.Len(t, cfg.Rules, 2) require.NotEmpty(t, cfg.Rules[0].ID) require.NotEmpty(t, cfg.Rules[1].ID) } func TestPutBucketLifecycleInvalidMD5(t *testing.T) { hc := prepareHandlerContext(t) bktName := "bucket-lifecycle-md5" createBucket(hc, bktName) lifecycle := &data.LifecycleConfiguration{ Rules: []data.LifecycleRule{ { Status: data.LifecycleStatusEnabled, Expiration: &data.LifecycleExpiration{ Days: ptr(21), }, }, }, } w, r := prepareTestRequest(hc, bktName, "", lifecycle) hc.Handler().PutBucketLifecycleHandler(w, r) assertS3Error(hc.t, w, apierr.GetAPIError(apierr.ErrMissingContentMD5)) w, r = prepareTestRequest(hc, bktName, "", lifecycle) r.Header.Set(api.ContentMD5, "") hc.Handler().PutBucketLifecycleHandler(w, r) assertS3Error(hc.t, w, apierr.GetAPIError(apierr.ErrInvalidDigest)) w, r = prepareTestRequest(hc, bktName, "", lifecycle) r.Header.Set(api.ContentMD5, "some-hash") hc.Handler().PutBucketLifecycleHandler(w, r) assertS3Error(hc.t, w, apierr.GetAPIError(apierr.ErrInvalidDigest)) } func TestPutBucketLifecycleInvalidXML(t *testing.T) { hc := prepareHandlerContext(t) bktName := "bucket-lifecycle-invalid-xml" createBucket(hc, bktName) cfg := &data.CORSConfiguration{} body, err := xml.Marshal(cfg) require.NoError(t, err) contentMD5, err := getContentMD5(bytes.NewReader(body)) require.NoError(t, err) w, r := prepareTestRequest(hc, bktName, "", cfg) r.Header.Set(api.ContentMD5, base64.StdEncoding.EncodeToString(contentMD5)) hc.Handler().PutBucketLifecycleHandler(w, r) assertS3Error(hc.t, w, apierr.GetAPIError(apierr.ErrMalformedXML)) } func putBucketLifecycleConfiguration(hc *handlerContext, bktName string, cfg *data.LifecycleConfiguration) { w := putBucketLifecycleConfigurationBase(hc, bktName, cfg) assertStatus(hc.t, w, http.StatusOK) } func putBucketLifecycleConfigurationErr(hc *handlerContext, bktName string, cfg *data.LifecycleConfiguration, err apierr.Error) { w := putBucketLifecycleConfigurationBase(hc, bktName, cfg) assertS3Error(hc.t, w, err) } func putBucketLifecycleConfigurationBase(hc *handlerContext, bktName string, cfg *data.LifecycleConfiguration) *httptest.ResponseRecorder { w, r := prepareTestRequest(hc, bktName, "", cfg) rawBody, err := xml.Marshal(cfg) require.NoError(hc.t, err) hash := md5.New() hash.Write(rawBody) r.Header.Set(api.ContentMD5, base64.StdEncoding.EncodeToString(hash.Sum(nil))) hc.Handler().PutBucketLifecycleHandler(w, r) return w } func getBucketLifecycleConfiguration(hc *handlerContext, bktName string) *data.LifecycleConfiguration { w := getBucketLifecycleConfigurationBase(hc, bktName) assertStatus(hc.t, w, http.StatusOK) res := &data.LifecycleConfiguration{} parseTestResponse(hc.t, w, res) return res } func getBucketLifecycleConfigurationErr(hc *handlerContext, bktName string, err apierr.Error) { w := getBucketLifecycleConfigurationBase(hc, bktName) assertS3Error(hc.t, w, err) } func getBucketLifecycleConfigurationBase(hc *handlerContext, bktName string) *httptest.ResponseRecorder { w, r := prepareTestRequest(hc, bktName, "", nil) hc.Handler().GetBucketLifecycleHandler(w, r) return w } func deleteBucketLifecycleConfiguration(hc *handlerContext, bktName string) { w := deleteBucketLifecycleConfigurationBase(hc, bktName) assertStatus(hc.t, w, http.StatusNoContent) } func deleteBucketLifecycleConfigurationBase(hc *handlerContext, bktName string) *httptest.ResponseRecorder { w, r := prepareTestRequest(hc, bktName, "", nil) hc.Handler().DeleteBucketLifecycleHandler(w, r) return w } func ptr[T any](t T) *T { return &t }