package handler import ( "bytes" "crypto/md5" "crypto/sha256" "encoding/hex" "encoding/xml" "fmt" "net/http" "net/http/httptest" "net/url" "strconv" "strings" "testing" "time" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/data" s3errors "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/errors" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/layer" "github.com/stretchr/testify/require" ) func TestPatch(t *testing.T) { tc := prepareHandlerContext(t) tc.config.md5Enabled = true bktName, objName := "bucket-for-patch", "object-for-patch" createTestBucket(tc, bktName) content := []byte("old object content") md5Hash := md5.New() md5Hash.Write(content) etag := data.Quote(hex.EncodeToString(md5Hash.Sum(nil))) w, r := prepareTestPayloadRequest(tc, bktName, objName, bytes.NewReader(content)) created := time.Now() tc.Handler().PutObjectHandler(w, r) require.Equal(t, etag, w.Header().Get(api.ETag)) patchPayload := []byte("new") sha256Hash := sha256.New() sha256Hash.Write(patchPayload) sha256Hash.Write(content[len(patchPayload):]) hash := hex.EncodeToString(sha256Hash.Sum(nil)) for _, tt := range []struct { name string rng string headers map[string]string code s3errors.ErrorCode }{ { name: "success", rng: "bytes 0-2/*", headers: map[string]string{ api.IfUnmodifiedSince: created.Format(http.TimeFormat), api.IfMatch: etag, }, }, { name: "invalid range syntax", rng: "bytes 0-2", code: s3errors.ErrInvalidRange, }, { name: "invalid range length", rng: "bytes 0-5/*", code: s3errors.ErrInvalidRangeLength, }, { name: "invalid range start", rng: "bytes 20-22/*", code: s3errors.ErrRangeOutOfBounds, }, { name: "range is too long", rng: "bytes 0-5368709120/*", code: s3errors.ErrInvalidRange, }, { name: "If-Unmodified-Since precondition are not satisfied", rng: "bytes 0-2/*", headers: map[string]string{ api.IfUnmodifiedSince: created.Add(-24 * time.Hour).Format(http.TimeFormat), }, code: s3errors.ErrPreconditionFailed, }, { name: "If-Match precondition are not satisfied", rng: "bytes 0-2/*", headers: map[string]string{ api.IfMatch: "etag", }, code: s3errors.ErrPreconditionFailed, }, } { t.Run(tt.name, func(t *testing.T) { if tt.code == 0 { res := patchObject(t, tc, bktName, objName, tt.rng, patchPayload, tt.headers) require.Equal(t, data.Quote(hash), res.Object.ETag) } else { patchObjectErr(t, tc, bktName, objName, tt.rng, patchPayload, tt.headers, tt.code) } }) } } func TestPatchWithVersion(t *testing.T) { hc := prepareHandlerContextWithMinCache(t) bktName, objName := "bucket", "obj" createVersionedBucket(hc, bktName) objHeader := putObjectContent(hc, bktName, objName, "content") putObjectContent(hc, bktName, objName, "some content") patchObjectVersion(t, hc, bktName, objName, objHeader.Get(api.AmzVersionID), "bytes 7-14/*", []byte(" updated")) res := listObjectsVersions(hc, bktName, "", "", "", "", 3) require.False(t, res.IsTruncated) require.Len(t, res.Version, 3) for _, version := range res.Version { content := getObjectVersion(hc, bktName, objName, version.VersionID) if version.IsLatest { require.Equal(t, []byte("content updated"), content) continue } if version.VersionID == objHeader.Get(api.AmzVersionID) { require.Equal(t, []byte("content"), content) continue } require.Equal(t, []byte("some content"), content) } } func TestPatchEncryptedObject(t *testing.T) { tc := prepareHandlerContext(t) bktName, objName := "bucket-for-patch-encrypted", "object-for-patch-encrypted" createTestBucket(tc, bktName) w, r := prepareTestPayloadRequest(tc, bktName, objName, strings.NewReader("object content")) setEncryptHeaders(r) tc.Handler().PutObjectHandler(w, r) assertStatus(t, w, http.StatusOK) patchObjectErr(t, tc, bktName, objName, "bytes 2-4/*", []byte("new"), nil, s3errors.ErrInternalError) } func TestPatchMissingHeaders(t *testing.T) { tc := prepareHandlerContext(t) bktName, objName := "bucket-for-patch-missing-headers", "object-for-patch-missing-headers" createTestBucket(tc, bktName) w, r := prepareTestPayloadRequest(tc, bktName, objName, strings.NewReader("object content")) setEncryptHeaders(r) tc.Handler().PutObjectHandler(w, r) assertStatus(t, w, http.StatusOK) w = httptest.NewRecorder() r = httptest.NewRequest(http.MethodPatch, defaultURL, strings.NewReader("new")) tc.Handler().PatchObjectHandler(w, r) assertS3Error(t, w, s3errors.GetAPIError(s3errors.ErrMissingContentRange)) w = httptest.NewRecorder() r = httptest.NewRequest(http.MethodPatch, defaultURL, strings.NewReader("new")) r.Header.Set(api.ContentRange, "bytes 0-2/*") tc.Handler().PatchObjectHandler(w, r) assertS3Error(t, w, s3errors.GetAPIError(s3errors.ErrMissingContentLength)) } func TestParsePatchByteRange(t *testing.T) { for _, tt := range []struct { rng string size uint64 expected *layer.RangeParams err bool }{ { rng: "bytes 2-7/*", expected: &layer.RangeParams{ Start: 2, End: 7, }, }, { rng: "bytes 2-7/3", expected: &layer.RangeParams{ Start: 2, End: 7, }, }, { rng: "bytes 2-/*", size: 9, expected: &layer.RangeParams{ Start: 2, End: 8, }, }, { rng: "bytes 2-/3", size: 9, expected: &layer.RangeParams{ Start: 2, End: 8, }, }, { rng: "", err: true, }, { rng: "2-7/*", err: true, }, { rng: "bytes 7-2/*", err: true, }, { rng: "bytes 2-7", err: true, }, { rng: "bytes 2/*", err: true, }, { rng: "bytes a-7/*", err: true, }, { rng: "bytes 2-a/*", err: true, }, } { t.Run(fmt.Sprintf("case: %s", tt.rng), func(t *testing.T) { rng, err := parsePatchByteRange(tt.rng, tt.size) if tt.err { require.Error(t, err) } else { require.NoError(t, err) require.Equal(t, tt.expected.Start, rng.Start) require.Equal(t, tt.expected.End, rng.End) } }) } } func patchObject(t *testing.T, tc *handlerContext, bktName, objName, rng string, payload []byte, headers map[string]string) *PatchObjectResult { w := patchObjectBase(tc, bktName, objName, "", rng, payload, headers) assertStatus(t, w, http.StatusOK) result := &PatchObjectResult{} err := xml.NewDecoder(w.Result().Body).Decode(result) require.NoError(t, err) return result } func patchObjectVersion(t *testing.T, tc *handlerContext, bktName, objName, version, rng string, payload []byte) *PatchObjectResult { w := patchObjectBase(tc, bktName, objName, version, rng, payload, nil) assertStatus(t, w, http.StatusOK) result := &PatchObjectResult{} err := xml.NewDecoder(w.Result().Body).Decode(result) require.NoError(t, err) return result } func patchObjectErr(t *testing.T, tc *handlerContext, bktName, objName, rng string, payload []byte, headers map[string]string, code s3errors.ErrorCode) { w := patchObjectBase(tc, bktName, objName, "", rng, payload, headers) assertS3Error(t, w, s3errors.GetAPIError(code)) } func patchObjectBase(tc *handlerContext, bktName, objName, version, rng string, payload []byte, headers map[string]string) *httptest.ResponseRecorder { query := make(url.Values) if len(version) > 0 { query.Add(api.QueryVersionID, version) } w, r := prepareTestRequestWithQuery(tc, bktName, objName, query, payload) r.Header.Set(api.ContentRange, rng) r.Header.Set(api.ContentLength, strconv.Itoa(len(payload))) for k, v := range headers { r.Header.Set(k, v) } tc.Handler().PatchObjectHandler(w, r) return w }