package s3api import ( "bytes" "context" "crypto/md5" "fmt" "strings" "testing" "github.com/aws/aws-sdk-go-v2/aws" "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/stretchr/testify/require" ) // TestMultipartUploadVersioningListETag tests that multipart uploaded objects // in versioned buckets have correct ETags when listed. // This covers a bug where synthetic entries for versioned objects didn't include // proper ETag handling for multipart uploads (ETags with format "-"). func TestMultipartUploadVersioningListETag(t *testing.T) { client := getS3Client(t) bucketName := getNewBucketName() // Create bucket createBucket(t, client, bucketName) defer deleteBucket(t, client, bucketName) // Enable versioning _, err := client.PutBucketVersioning(context.TODO(), &s3.PutBucketVersioningInput{ Bucket: aws.String(bucketName), VersioningConfiguration: &types.VersioningConfiguration{ Status: types.BucketVersioningStatusEnabled, }, }) require.NoError(t, err, "Failed to enable versioning") // Create multipart upload objectKey := "multipart-test-object" createResp, err := client.CreateMultipartUpload(context.TODO(), &s3.CreateMultipartUploadInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), }) require.NoError(t, err, "Failed to create multipart upload") uploadId := *createResp.UploadId // Upload 2 parts (minimum 5MB per part except last) partSize := 5 * 1024 * 1024 // 5MB part1Data := bytes.Repeat([]byte("a"), partSize) part2Data := bytes.Repeat([]byte("b"), partSize) // Calculate MD5 for each part part1MD5 := md5.Sum(part1Data) part2MD5 := md5.Sum(part2Data) // Upload part 1 uploadPart1Resp, err := client.UploadPart(context.TODO(), &s3.UploadPartInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), UploadId: aws.String(uploadId), PartNumber: aws.Int32(1), Body: bytes.NewReader(part1Data), }) require.NoError(t, err, "Failed to upload part 1") // Upload part 2 uploadPart2Resp, err := client.UploadPart(context.TODO(), &s3.UploadPartInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), UploadId: aws.String(uploadId), PartNumber: aws.Int32(2), Body: bytes.NewReader(part2Data), }) require.NoError(t, err, "Failed to upload part 2") // Complete multipart upload completeResp, err := client.CompleteMultipartUpload(context.TODO(), &s3.CompleteMultipartUploadInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), UploadId: aws.String(uploadId), MultipartUpload: &types.CompletedMultipartUpload{ Parts: []types.CompletedPart{ { ETag: uploadPart1Resp.ETag, PartNumber: aws.Int32(1), }, { ETag: uploadPart2Resp.ETag, PartNumber: aws.Int32(2), }, }, }, }) require.NoError(t, err, "Failed to complete multipart upload") // Verify the ETag from CompleteMultipartUpload has the multipart format (md5-parts) completeETag := strings.Trim(*completeResp.ETag, "\"") assert.Contains(t, completeETag, "-", "Multipart ETag should contain '-' (format: md5-parts)") assert.True(t, strings.HasSuffix(completeETag, "-2"), "Multipart ETag should end with '-2' for 2 parts") t.Logf("CompleteMultipartUpload ETag: %s", completeETag) t.Logf("Part 1 MD5: %x", part1MD5) t.Logf("Part 2 MD5: %x", part2MD5) // HeadObject should return the same ETag headResp, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), }) require.NoError(t, err, "Failed to head object") headETag := strings.Trim(*headResp.ETag, "\"") assert.Equal(t, completeETag, headETag, "HeadObject ETag should match CompleteMultipartUpload ETag") // ListObjectsV2 should return the same ETag listResp, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{ Bucket: aws.String(bucketName), Prefix: aws.String(objectKey), }) require.NoError(t, err, "Failed to list objects") require.Len(t, listResp.Contents, 1, "Should have exactly one object") listETag := strings.Trim(*listResp.Contents[0].ETag, "\"") assert.Equal(t, completeETag, listETag, "ListObjectsV2 ETag should match CompleteMultipartUpload ETag") assert.NotEmpty(t, listETag, "ListObjectsV2 ETag should not be empty") t.Logf("ListObjectsV2 ETag: %s", listETag) // ListObjectVersions should also return the correct ETag versionsResp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{ Bucket: aws.String(bucketName), Prefix: aws.String(objectKey), }) require.NoError(t, err, "Failed to list object versions") require.Len(t, versionsResp.Versions, 1, "Should have exactly one version") versionETag := strings.Trim(*versionsResp.Versions[0].ETag, "\"") assert.Equal(t, completeETag, versionETag, "ListObjectVersions ETag should match CompleteMultipartUpload ETag") assert.NotEmpty(t, versionETag, "ListObjectVersions ETag should not be empty") t.Logf("ListObjectVersions ETag: %s", versionETag) } // TestMultipartUploadMultipleVersionsListETag tests that multiple versions // of multipart uploaded objects all have correct ETags when listed. func TestMultipartUploadMultipleVersionsListETag(t *testing.T) { client := getS3Client(t) bucketName := getNewBucketName() // Create bucket createBucket(t, client, bucketName) defer deleteBucket(t, client, bucketName) // Enable versioning _, err := client.PutBucketVersioning(context.TODO(), &s3.PutBucketVersioningInput{ Bucket: aws.String(bucketName), VersioningConfiguration: &types.VersioningConfiguration{ Status: types.BucketVersioningStatusEnabled, }, }) require.NoError(t, err, "Failed to enable versioning") objectKey := "multipart-multi-version-object" partSize := 5 * 1024 * 1024 // 5MB var expectedETags []string // Create 3 versions using multipart upload for version := 1; version <= 3; version++ { // Create multipart upload createResp, err := client.CreateMultipartUpload(context.TODO(), &s3.CreateMultipartUploadInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), }) require.NoError(t, err, "Failed to create multipart upload for version %d", version) uploadId := *createResp.UploadId // Create unique data for each version partData := bytes.Repeat([]byte(fmt.Sprintf("%d", version)), partSize) // Upload single part (still results in multipart ETag format) uploadPartResp, err := client.UploadPart(context.TODO(), &s3.UploadPartInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), UploadId: aws.String(uploadId), PartNumber: aws.Int32(1), Body: bytes.NewReader(partData), }) require.NoError(t, err, "Failed to upload part for version %d", version) // Complete multipart upload completeResp, err := client.CompleteMultipartUpload(context.TODO(), &s3.CompleteMultipartUploadInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), UploadId: aws.String(uploadId), MultipartUpload: &types.CompletedMultipartUpload{ Parts: []types.CompletedPart{ { ETag: uploadPartResp.ETag, PartNumber: aws.Int32(1), }, }, }, }) require.NoError(t, err, "Failed to complete multipart upload for version %d", version) etag := strings.Trim(*completeResp.ETag, "\"") expectedETags = append(expectedETags, etag) t.Logf("Version %d ETag: %s", version, etag) } // ListObjectVersions should return all versions with correct ETags versionsResp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{ Bucket: aws.String(bucketName), Prefix: aws.String(objectKey), }) require.NoError(t, err, "Failed to list object versions") require.Len(t, versionsResp.Versions, 3, "Should have exactly 3 versions") // Collect ETags from the listing var listedETags []string for _, v := range versionsResp.Versions { etag := strings.Trim(*v.ETag, "\"") listedETags = append(listedETags, etag) assert.NotEmpty(t, etag, "Version ETag should not be empty") assert.Contains(t, etag, "-", "Multipart ETag should contain '-'") } t.Logf("Expected ETags: %v", expectedETags) t.Logf("Listed ETags: %v", listedETags) // Verify all expected ETags are present (order may differ due to version ordering) assert.ElementsMatch(t, expectedETags, listedETags, "Listed ETags should match all expected ETags") // Regular ListObjectsV2 should return only the latest version with correct ETag listResp, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{ Bucket: aws.String(bucketName), Prefix: aws.String(objectKey), }) require.NoError(t, err, "Failed to list objects") require.Len(t, listResp.Contents, 1, "Should have exactly one object in regular listing") listETag := strings.Trim(*listResp.Contents[0].ETag, "\"") // The latest version (version 3) should be the one shown assert.Equal(t, expectedETags[2], listETag, "ListObjectsV2 should show latest version's ETag") } // TestMixedSingleAndMultipartVersionsListETag tests that a mix of // single-part and multipart uploaded versions all have correct ETags. func TestMixedSingleAndMultipartVersionsListETag(t *testing.T) { client := getS3Client(t) bucketName := getNewBucketName() // Create bucket createBucket(t, client, bucketName) defer deleteBucket(t, client, bucketName) // Enable versioning _, err := client.PutBucketVersioning(context.TODO(), &s3.PutBucketVersioningInput{ Bucket: aws.String(bucketName), VersioningConfiguration: &types.VersioningConfiguration{ Status: types.BucketVersioningStatusEnabled, }, }) require.NoError(t, err, "Failed to enable versioning") objectKey := "mixed-upload-versions" // Version 1: Regular PutObject (single-part, pure MD5 ETag) content1 := []byte("This is version 1 content - single part upload") putResp1, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), Body: bytes.NewReader(content1), }) require.NoError(t, err, "Failed to put version 1") etag1 := strings.Trim(*putResp1.ETag, "\"") assert.NotContains(t, etag1, "-", "Single-part ETag should not contain '-'") t.Logf("Version 1 (PutObject) ETag: %s", etag1) // Version 2: Multipart upload partSize := 5 * 1024 * 1024 partData := bytes.Repeat([]byte("x"), partSize) createResp, err := client.CreateMultipartUpload(context.TODO(), &s3.CreateMultipartUploadInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), }) require.NoError(t, err, "Failed to create multipart upload") uploadPartResp, err := client.UploadPart(context.TODO(), &s3.UploadPartInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), UploadId: createResp.UploadId, PartNumber: aws.Int32(1), Body: bytes.NewReader(partData), }) require.NoError(t, err, "Failed to upload part") completeResp, err := client.CompleteMultipartUpload(context.TODO(), &s3.CompleteMultipartUploadInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), UploadId: createResp.UploadId, MultipartUpload: &types.CompletedMultipartUpload{ Parts: []types.CompletedPart{ { ETag: uploadPartResp.ETag, PartNumber: aws.Int32(1), }, }, }, }) require.NoError(t, err, "Failed to complete multipart upload") etag2 := strings.Trim(*completeResp.ETag, "\"") assert.Contains(t, etag2, "-", "Multipart ETag should contain '-'") t.Logf("Version 2 (Multipart) ETag: %s", etag2) // Version 3: Another regular PutObject content3 := []byte("This is version 3 content - another single part upload") putResp3, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), Body: bytes.NewReader(content3), }) require.NoError(t, err, "Failed to put version 3") etag3 := strings.Trim(*putResp3.ETag, "\"") assert.NotContains(t, etag3, "-", "Single-part ETag should not contain '-'") t.Logf("Version 3 (PutObject) ETag: %s", etag3) // ListObjectVersions should return all 3 versions with correct ETags versionsResp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{ Bucket: aws.String(bucketName), Prefix: aws.String(objectKey), }) require.NoError(t, err, "Failed to list object versions") require.Len(t, versionsResp.Versions, 3, "Should have exactly 3 versions") var listedETags []string for _, v := range versionsResp.Versions { etag := strings.Trim(*v.ETag, "\"") assert.NotEmpty(t, etag, "Version ETag should not be empty") listedETags = append(listedETags, etag) t.Logf("Listed version %s ETag: %s, IsLatest: %v", *v.VersionId, etag, *v.IsLatest) } // Verify all ETags were found assert.ElementsMatch(t, []string{etag1, etag2, etag3}, listedETags, "Listed ETags should match all expected ETags") // Regular ListObjectsV2 should return only the latest (version 3) listResp, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{ Bucket: aws.String(bucketName), Prefix: aws.String(objectKey), }) require.NoError(t, err, "Failed to list objects") require.Len(t, listResp.Contents, 1, "Should have exactly one object") listETag := strings.Trim(*listResp.Contents[0].ETag, "\"") assert.Equal(t, etag3, listETag, "ListObjectsV2 should show latest version's ETag (version 3)") } // TestMultipartUploadDeleteMarkerListBehavior tests that delete markers work correctly // with multipart uploaded objects in versioned buckets. func TestMultipartUploadDeleteMarkerListBehavior(t *testing.T) { client := getS3Client(t) bucketName := getNewBucketName() // Create bucket createBucket(t, client, bucketName) defer deleteBucket(t, client, bucketName) // Enable versioning _, err := client.PutBucketVersioning(context.TODO(), &s3.PutBucketVersioningInput{ Bucket: aws.String(bucketName), VersioningConfiguration: &types.VersioningConfiguration{ Status: types.BucketVersioningStatusEnabled, }, }) require.NoError(t, err, "Failed to enable versioning") objectKey := "multipart-delete-marker-test" partSize := 5 * 1024 * 1024 // 5MB // Create multipart upload createResp, err := client.CreateMultipartUpload(context.TODO(), &s3.CreateMultipartUploadInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), }) require.NoError(t, err, "Failed to create multipart upload") // Upload 2 parts part1Data := bytes.Repeat([]byte("a"), partSize) part2Data := bytes.Repeat([]byte("b"), partSize) uploadPart1Resp, err := client.UploadPart(context.TODO(), &s3.UploadPartInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), UploadId: createResp.UploadId, PartNumber: aws.Int32(1), Body: bytes.NewReader(part1Data), }) require.NoError(t, err, "Failed to upload part 1") uploadPart2Resp, err := client.UploadPart(context.TODO(), &s3.UploadPartInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), UploadId: createResp.UploadId, PartNumber: aws.Int32(2), Body: bytes.NewReader(part2Data), }) require.NoError(t, err, "Failed to upload part 2") // Complete multipart upload completeResp, err := client.CompleteMultipartUpload(context.TODO(), &s3.CompleteMultipartUploadInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), UploadId: createResp.UploadId, MultipartUpload: &types.CompletedMultipartUpload{ Parts: []types.CompletedPart{ {ETag: uploadPart1Resp.ETag, PartNumber: aws.Int32(1)}, {ETag: uploadPart2Resp.ETag, PartNumber: aws.Int32(2)}, }, }, }) require.NoError(t, err, "Failed to complete multipart upload") multipartETag := strings.Trim(*completeResp.ETag, "\"") multipartVersionId := *completeResp.VersionId t.Logf("Multipart upload completed: ETag=%s, VersionId=%s", multipartETag, multipartVersionId) // Verify object is visible in ListObjectsV2 listBeforeDelete, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{ Bucket: aws.String(bucketName), Prefix: aws.String(objectKey), }) require.NoError(t, err, "Failed to list objects before delete") require.Len(t, listBeforeDelete.Contents, 1, "Object should be visible before delete") assert.Equal(t, multipartETag, strings.Trim(*listBeforeDelete.Contents[0].ETag, "\""), "Listed ETag should match multipart ETag before delete") // Delete object (creates delete marker) deleteResp, err := client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), }) require.NoError(t, err, "Failed to delete object") require.NotNil(t, deleteResp.DeleteMarker, "Should create delete marker") assert.True(t, *deleteResp.DeleteMarker, "DeleteMarker should be true") require.NotNil(t, deleteResp.VersionId, "Delete marker should have version ID") deleteMarkerVersionId := *deleteResp.VersionId t.Logf("Delete marker created: VersionId=%s", deleteMarkerVersionId) // ListObjectsV2 should NOT show the object anymore listAfterDelete, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{ Bucket: aws.String(bucketName), Prefix: aws.String(objectKey), }) require.NoError(t, err, "Failed to list objects after delete") assert.Empty(t, listAfterDelete.Contents, "Object should NOT be visible after delete marker") // ListObjectVersions should show both the original version AND the delete marker versionsResp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{ Bucket: aws.String(bucketName), Prefix: aws.String(objectKey), }) require.NoError(t, err, "Failed to list object versions") // Should have 1 version (the multipart object) require.Len(t, versionsResp.Versions, 1, "Should have exactly 1 version (the multipart object)") version := versionsResp.Versions[0] assert.Equal(t, multipartVersionId, *version.VersionId, "Version ID should match") assert.Equal(t, multipartETag, strings.Trim(*version.ETag, "\""), "Version ETag should match multipart ETag") assert.False(t, *version.IsLatest, "Multipart version should NOT be latest (delete marker is latest)") // Should have 1 delete marker require.Len(t, versionsResp.DeleteMarkers, 1, "Should have exactly 1 delete marker") deleteMarker := versionsResp.DeleteMarkers[0] assert.Equal(t, deleteMarkerVersionId, *deleteMarker.VersionId, "Delete marker version ID should match") assert.True(t, *deleteMarker.IsLatest, "Delete marker should be latest") t.Logf("ListObjectVersions: 1 version (ETag=%s), 1 delete marker", multipartETag) // Access the specific version by version ID - should still work getResp, err := client.GetObject(context.TODO(), &s3.GetObjectInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), VersionId: aws.String(multipartVersionId), }) require.NoError(t, err, "Should be able to get object by version ID after delete marker") defer getResp.Body.Close() assert.Equal(t, multipartETag, strings.Trim(*getResp.ETag, "\""), "GetObject with version ID should return correct ETag") assert.Equal(t, int64(partSize*2), *getResp.ContentLength, "GetObject with version ID should return correct size") t.Logf("Successfully retrieved version %s after delete marker", multipartVersionId) // Delete the delete marker to "undelete" the object undeleteResp, err := client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), VersionId: aws.String(deleteMarkerVersionId), }) require.NoError(t, err, "Failed to delete the delete marker") require.NotNil(t, undeleteResp.DeleteMarker, "Deleting a delete marker version should report DeleteMarker=true") assert.True(t, *undeleteResp.DeleteMarker, "Deleting a delete marker version should report DeleteMarker=true") require.NotNil(t, undeleteResp.VersionId, "Deleting a delete marker version should echo the version ID") assert.Equal(t, deleteMarkerVersionId, *undeleteResp.VersionId, "DeleteObject should report the deleted delete-marker version ID") // ListObjectsV2 should show the object again listAfterUndelete, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{ Bucket: aws.String(bucketName), Prefix: aws.String(objectKey), }) require.NoError(t, err, "Failed to list objects after undelete") require.Len(t, listAfterUndelete.Contents, 1, "Object should be visible again after removing delete marker") assert.Equal(t, multipartETag, strings.Trim(*listAfterUndelete.Contents[0].ETag, "\""), "Undeleted object should have correct multipart ETag") t.Logf("Object restored after delete marker removal, ETag=%s", multipartETag) } func TestVersioningCompleteMultipartUploadIsIdempotent(t *testing.T) { client := getS3Client(t) bucketName := getNewBucketName() createBucket(t, client, bucketName) defer deleteBucket(t, client, bucketName) enableVersioning(t, client, bucketName) checkVersioningStatus(t, client, bucketName, types.BucketVersioningStatusEnabled) objectKey := "multipart-idempotent-object" createResp, err := client.CreateMultipartUpload(context.TODO(), &s3.CreateMultipartUploadInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), }) require.NoError(t, err, "Failed to create multipart upload") partSize := 5 * 1024 * 1024 part1Data := bytes.Repeat([]byte("i"), partSize) part2Data := bytes.Repeat([]byte("j"), partSize) uploadPart1Resp, err := client.UploadPart(context.TODO(), &s3.UploadPartInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), UploadId: createResp.UploadId, PartNumber: aws.Int32(1), Body: bytes.NewReader(part1Data), }) require.NoError(t, err, "Failed to upload first part") uploadPart2Resp, err := client.UploadPart(context.TODO(), &s3.UploadPartInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), UploadId: createResp.UploadId, PartNumber: aws.Int32(2), Body: bytes.NewReader(part2Data), }) require.NoError(t, err, "Failed to upload second part") completeInput := &s3.CompleteMultipartUploadInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), UploadId: createResp.UploadId, MultipartUpload: &types.CompletedMultipartUpload{ Parts: []types.CompletedPart{ {ETag: uploadPart1Resp.ETag, PartNumber: aws.Int32(1)}, {ETag: uploadPart2Resp.ETag, PartNumber: aws.Int32(2)}, }, }, } firstCompleteResp, err := client.CompleteMultipartUpload(context.TODO(), completeInput) require.NoError(t, err, "First CompleteMultipartUpload should succeed") require.NotNil(t, firstCompleteResp.ETag) require.NotNil(t, firstCompleteResp.VersionId) secondCompleteResp, err := client.CompleteMultipartUpload(context.TODO(), completeInput) require.NoError(t, err, "Repeated CompleteMultipartUpload should return the existing object instead of creating a second version") require.NotNil(t, secondCompleteResp.ETag) require.NotNil(t, secondCompleteResp.VersionId, "Repeated complete should report the existing version ID") assert.Equal(t, *firstCompleteResp.ETag, *secondCompleteResp.ETag, "Repeated complete should report the same ETag") assert.Equal(t, *firstCompleteResp.VersionId, *secondCompleteResp.VersionId, "Repeated complete should report the same version ID") versionsResp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{ Bucket: aws.String(bucketName), Prefix: aws.String(objectKey), }) require.NoError(t, err, "Failed to list object versions") require.Len(t, versionsResp.Versions, 1, "Repeated completion must not create a duplicate version") assert.Equal(t, *firstCompleteResp.VersionId, *versionsResp.Versions[0].VersionId, "The original multipart version should remain current") assert.Empty(t, versionsResp.DeleteMarkers, "Repeated completion should not create delete markers") }