Browse Source
Fix: ListObjectVersions delimiter support (#7987)
Fix: ListObjectVersions delimiter support (#7987)
* Fix: Add delimiter support to ListObjectVersions with proper truncation - Implemented delimiter support to group keys into CommonPrefixes - Fixed critical truncation bug: now merges versions and common prefixes into single sorted list before truncation - Ensures total items never exceed MaxKeys (prevents infinite pagination loops) - Properly sets NextKeyMarker and NextVersionIdMarker for pagination - Added integration tests in test/s3/versioning/s3_versioning_delimiter_test.go - Verified behavior matches S3 API specification * Fix: Add delimiter support to ListObjectVersions with proper truncation - Implemented delimiter support to group keys into CommonPrefixes - Fixed critical truncation bug: now merges versions and common prefixes before truncation - Added safety guard for maxKeys=0 to prevent panics - Condensed verbose comments for better readability - Added robust Go integration tests with nil checks for AWS SDK pointers - Verified behavior matches S3 API specification - Resolved compilation error in integration tests - Refined pagination comments and ensured exclusive KeyMarker behavior - Refactored listObjectVersions into helper methods for better maintainabilitypull/7988/head
committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 427 additions and 77 deletions
@ -0,0 +1,274 @@ |
|||
package s3api |
|||
|
|||
import ( |
|||
"context" |
|||
"fmt" |
|||
"testing" |
|||
|
|||
"github.com/aws/aws-sdk-go-v2/aws" |
|||
"github.com/aws/aws-sdk-go-v2/service/s3" |
|||
"github.com/stretchr/testify/assert" |
|||
"github.com/stretchr/testify/require" |
|||
) |
|||
|
|||
// TestListObjectVersionsDelimiter tests delimiter functionality in ListObjectVersions
|
|||
func TestListObjectVersionsDelimiter(t *testing.T) { |
|||
client := getS3Client(t) |
|||
bucketName := getNewBucketName() |
|||
|
|||
// Create bucket and enable versioning
|
|||
createBucket(t, client, bucketName) |
|||
defer deleteBucket(t, client, bucketName) |
|||
enableVersioning(t, client, bucketName) |
|||
|
|||
// Create test structure:
|
|||
// - folder1/file1.txt
|
|||
// - folder1/file2.txt
|
|||
// - folder2/file3.txt
|
|||
// - root-file.txt
|
|||
testObjects := []string{ |
|||
"folder1/file1.txt", |
|||
"folder1/file2.txt", |
|||
"folder2/file3.txt", |
|||
"root-file.txt", |
|||
} |
|||
|
|||
for _, key := range testObjects { |
|||
putObject(t, client, bucketName, key, fmt.Sprintf("Content of %s", key)) |
|||
} |
|||
|
|||
t.Run("Delimiter groups folders correctly", func(t *testing.T) { |
|||
// List with delimiter='/' and no prefix
|
|||
// Should return: root-file.txt and CommonPrefixes: folder1/, folder2/
|
|||
resp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Delimiter: aws.String("/"), |
|||
}) |
|||
require.NoError(t, err) |
|||
|
|||
// Extract keys and prefixes
|
|||
versionKeys := make([]string, 0) |
|||
for _, v := range resp.Versions { |
|||
versionKeys = append(versionKeys, *v.Key) |
|||
} |
|||
|
|||
prefixValues := make([]string, 0) |
|||
for _, p := range resp.CommonPrefixes { |
|||
prefixValues = append(prefixValues, *p.Prefix) |
|||
} |
|||
|
|||
// Verify results
|
|||
assert.Contains(t, versionKeys, "root-file.txt", "Should include root-file.txt") |
|||
assert.Contains(t, prefixValues, "folder1/", "Should include folder1/ prefix") |
|||
assert.Contains(t, prefixValues, "folder2/", "Should include folder2/ prefix") |
|||
assert.NotContains(t, versionKeys, "folder1/file1.txt", "folder1/file1.txt should be grouped under folder1/") |
|||
assert.NotContains(t, versionKeys, "folder2/file3.txt", "folder2/file3.txt should be grouped under folder2/") |
|||
|
|||
t.Logf("✓ Versions: %v", versionKeys) |
|||
t.Logf("✓ CommonPrefixes: %v", prefixValues) |
|||
}) |
|||
|
|||
t.Run("Prefix filtering with delimiter", func(t *testing.T) { |
|||
// List with delimiter='/' and prefix='folder1/'
|
|||
// Should return: folder1/file1.txt, folder1/file2.txt
|
|||
resp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Prefix: aws.String("folder1/"), |
|||
Delimiter: aws.String("/"), |
|||
}) |
|||
require.NoError(t, err) |
|||
|
|||
versionKeys := make([]string, 0) |
|||
for _, v := range resp.Versions { |
|||
versionKeys = append(versionKeys, *v.Key) |
|||
} |
|||
|
|||
assert.Len(t, versionKeys, 2, "Should have 2 versions") |
|||
assert.Contains(t, versionKeys, "folder1/file1.txt") |
|||
assert.Contains(t, versionKeys, "folder1/file2.txt") |
|||
assert.Empty(t, resp.CommonPrefixes, "Should have no common prefixes") |
|||
|
|||
t.Logf("✓ Prefix filtering works: %v", versionKeys) |
|||
}) |
|||
|
|||
t.Run("Without delimiter returns all versions", func(t *testing.T) { |
|||
// List without delimiter - should return all files
|
|||
resp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{ |
|||
Bucket: aws.String(bucketName), |
|||
}) |
|||
require.NoError(t, err) |
|||
|
|||
assert.Len(t, resp.Versions, 4, "Should have all 4 versions") |
|||
assert.Empty(t, resp.CommonPrefixes, "Should have no common prefixes without delimiter") |
|||
|
|||
t.Logf("✓ Without delimiter returns all %d versions", len(resp.Versions)) |
|||
}) |
|||
} |
|||
|
|||
// TestListObjectVersionsDelimiterTruncation tests MaxKeys with delimiter
|
|||
func TestListObjectVersionsDelimiterTruncation(t *testing.T) { |
|||
client := getS3Client(t) |
|||
bucketName := getNewBucketName() |
|||
|
|||
// Create bucket and enable versioning
|
|||
createBucket(t, client, bucketName) |
|||
defer deleteBucket(t, client, bucketName) |
|||
enableVersioning(t, client, bucketName) |
|||
|
|||
// Create multiple folders to test truncation
|
|||
for i := 0; i < 5; i++ { |
|||
key := fmt.Sprintf("folder%d/file.txt", i) |
|||
putObject(t, client, bucketName, key, fmt.Sprintf("Content %d", i)) |
|||
} |
|||
// Add a root file
|
|||
putObject(t, client, bucketName, "root.txt", "Root content") |
|||
|
|||
t.Run("MaxKeys limits total items", func(t *testing.T) { |
|||
resp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Delimiter: aws.String("/"), |
|||
MaxKeys: aws.Int32(3), |
|||
}) |
|||
require.NoError(t, err) |
|||
|
|||
assert.NotNil(t, resp.IsTruncated, "IsTruncated should not be nil") |
|||
assert.True(t, *resp.IsTruncated, "Should be truncated") |
|||
assert.NotNil(t, resp.NextKeyMarker, "Should have NextKeyMarker for pagination") |
|||
|
|||
count := len(resp.Versions) + len(resp.CommonPrefixes) |
|||
t.Logf("✓ MaxKeys truncation: %d items (versions: %d, prefixes: %d)", |
|||
count, len(resp.Versions), len(resp.CommonPrefixes)) |
|||
}) |
|||
|
|||
t.Run("Pagination with delimiter", func(t *testing.T) { |
|||
// Collect all items through pagination
|
|||
allKeys := make([]string, 0) |
|||
allPrefixes := make([]string, 0) |
|||
var keyMarker *string |
|||
var versionMarker *string |
|||
|
|||
for { |
|||
input := &s3.ListObjectVersionsInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Delimiter: aws.String("/"), |
|||
MaxKeys: aws.Int32(2), |
|||
} |
|||
if keyMarker != nil { |
|||
input.KeyMarker = keyMarker |
|||
} |
|||
if versionMarker != nil { |
|||
input.VersionIdMarker = versionMarker |
|||
} |
|||
|
|||
resp, err := client.ListObjectVersions(context.TODO(), input) |
|||
require.NoError(t, err) |
|||
|
|||
// Collect versions
|
|||
for _, v := range resp.Versions { |
|||
allKeys = append(allKeys, *v.Key) |
|||
} |
|||
|
|||
// Collect prefixes
|
|||
for _, p := range resp.CommonPrefixes { |
|||
allPrefixes = append(allPrefixes, *p.Prefix) |
|||
} |
|||
|
|||
require.NotNil(t, resp.IsTruncated, "IsTruncated should not be nil") |
|||
if !*resp.IsTruncated { |
|||
break |
|||
} |
|||
|
|||
keyMarker = resp.NextKeyMarker |
|||
versionMarker = resp.NextVersionIdMarker |
|||
} |
|||
|
|||
// Should have collected all items
|
|||
itemsCount := len(allKeys) + len(allPrefixes) |
|||
assert.GreaterOrEqual(t, itemsCount, 6, "Should collect all items through pagination") |
|||
|
|||
t.Logf("✓ Pagination collected %d total items (keys: %d, prefixes: %d)", |
|||
itemsCount, len(allKeys), len(allPrefixes)) |
|||
}) |
|||
|
|||
t.Run("CommonPrefixes are filtered by keyMarker (exclusive)", func(t *testing.T) { |
|||
// List with keyMarker that should skip some prefixes
|
|||
// We have folder0/, folder1/, folder2/, folder3/, folder4/
|
|||
// Setting keyMarker to "folder2/" should return folder3/, folder4/ and root.txt (if it's > folder2/)
|
|||
resp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Delimiter: aws.String("/"), |
|||
KeyMarker: aws.String("folder2/"), |
|||
}) |
|||
require.NoError(t, err) |
|||
|
|||
prefixValues := make([]string, 0) |
|||
for _, p := range resp.CommonPrefixes { |
|||
prefixValues = append(prefixValues, *p.Prefix) |
|||
} |
|||
|
|||
assert.NotContains(t, prefixValues, "folder0/", "Should skip folder0/") |
|||
assert.NotContains(t, prefixValues, "folder1/", "Should skip folder1/") |
|||
assert.NotContains(t, prefixValues, "folder2/", "Should skip folder2/ (exclusive marker)") |
|||
assert.Contains(t, prefixValues, "folder3/", "Should include folder3/") |
|||
assert.Contains(t, prefixValues, "folder4/", "Should include folder4/") |
|||
|
|||
t.Logf("✓ CommonPrefixes filtered by keyMarker: %v", prefixValues) |
|||
}) |
|||
} |
|||
|
|||
// TestListObjectVersionsDelimiterWithMultipleVersions tests delimiter with multiple versions of same object
|
|||
func TestListObjectVersionsDelimiterWithMultipleVersions(t *testing.T) { |
|||
client := getS3Client(t) |
|||
bucketName := getNewBucketName() |
|||
|
|||
// Create bucket and enable versioning
|
|||
createBucket(t, client, bucketName) |
|||
defer deleteBucket(t, client, bucketName) |
|||
enableVersioning(t, client, bucketName) |
|||
|
|||
// Create multiple versions of objects in different folders
|
|||
for i := 1; i <= 3; i++ { |
|||
putObject(t, client, bucketName, "folder1/file.txt", fmt.Sprintf("Version %d", i)) |
|||
putObject(t, client, bucketName, "folder2/file.txt", fmt.Sprintf("Version %d", i)) |
|||
} |
|||
|
|||
t.Run("Delimiter groups all versions under prefix", func(t *testing.T) { |
|||
resp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Delimiter: aws.String("/"), |
|||
}) |
|||
require.NoError(t, err) |
|||
|
|||
// Should have no versions at root level, only common prefixes
|
|||
assert.Empty(t, resp.Versions, "Should have no versions at root") |
|||
assert.Len(t, resp.CommonPrefixes, 2, "Should have 2 common prefixes") |
|||
|
|||
prefixValues := make([]string, 0) |
|||
for _, p := range resp.CommonPrefixes { |
|||
prefixValues = append(prefixValues, *p.Prefix) |
|||
} |
|||
assert.Contains(t, prefixValues, "folder1/") |
|||
assert.Contains(t, prefixValues, "folder2/") |
|||
|
|||
t.Logf("✓ All versions grouped under prefixes: %v", prefixValues) |
|||
}) |
|||
|
|||
t.Run("Listing within prefix shows all versions", func(t *testing.T) { |
|||
resp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Prefix: aws.String("folder1/"), |
|||
Delimiter: aws.String("/"), |
|||
}) |
|||
require.NoError(t, err) |
|||
|
|||
assert.Len(t, resp.Versions, 3, "Should have 3 versions of folder1/file.txt") |
|||
assert.Empty(t, resp.CommonPrefixes, "Should have no common prefixes") |
|||
|
|||
// Verify all versions are for the same key
|
|||
for _, v := range resp.Versions { |
|||
assert.Equal(t, "folder1/file.txt", *v.Key) |
|||
} |
|||
|
|||
t.Logf("✓ Found %d versions within prefix", len(resp.Versions)) |
|||
}) |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue