package s3api import ( "context" "sort" "strings" "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" ) // Deleting a key whose null version predates versioning records the delete // marker under .versions but leaves the null object at the base path. // The listing must retract that stale entry, on whichever page it lands. func deleteObject(t *testing.T, client *s3.Client, bucket, key string) { t.Helper() resp, err := client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), }) require.NoError(t, err) require.True(t, resp.DeleteMarker != nil && *resp.DeleteMarker, "the delete must record a delete marker") } func TestPreVersioningNullObjectDeleteHidesKey(t *testing.T) { client := getS3Client(t) bucketName := getNewBucketName() createBucket(t, client, bucketName) defer deleteBucket(t, client, bucketName) putObject(t, client, bucketName, "k.txt", "pre-versioning") enableVersioning(t, client, bucketName) deleteObject(t, client, bucketName, "k.txt") assert.Empty(t, listAllKeys(t, client, bucketName, 0), "the deleted key must leave the listing") _, err := client.GetObject(context.TODO(), &s3.GetObjectInput{ Bucket: aws.String(bucketName), Key: aws.String("k.txt"), }) assert.Error(t, err, "the deleted key must not be readable") versions, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{ Bucket: aws.String(bucketName), }) require.NoError(t, err) require.Len(t, versions.DeleteMarkers, 1) assert.True(t, *versions.DeleteMarkers[0].IsLatest, "the delete marker is the current version") require.Len(t, versions.Versions, 1) assert.Equal(t, "null", *versions.Versions[0].VersionId) assert.False(t, *versions.Versions[0].IsLatest, "the null version is shadowed by the marker") } func TestPreVersioningNullObjectDeleteAfterOverwriteHidesKey(t *testing.T) { client := getS3Client(t) bucketName := getNewBucketName() createBucket(t, client, bucketName) defer deleteBucket(t, client, bucketName) putObject(t, client, bucketName, "k.txt", "pre-versioning") enableVersioning(t, client, bucketName) putObject(t, client, bucketName, "k.txt", "versioned overwrite") deleteObject(t, client, bucketName, "k.txt") assert.Empty(t, listAllKeys(t, client, bucketName, 0), "the deleted key must leave the listing") } // The retraction and the replacement must both survive a page boundary landing // between a key and its .versions sibling. func TestPreVersioningNullObjectAcrossPageBoundary(t *testing.T) { client := getS3Client(t) bucketName := getNewBucketName() createBucket(t, client, bucketName) defer deleteBucket(t, client, bucketName) putObject(t, client, bucketName, "a.txt", "old") enableVersioning(t, client, bucketName) deleteObject(t, client, bucketName, "a.txt") putObjectVersioned(t, client, bucketName, "b.txt") assert.Equal(t, []string{"b.txt"}, listAllKeys(t, client, bucketName, 1), "a page ending on the stale null object must still retract it") } func TestPreVersioningNullObjectMetadataAcrossPageBoundary(t *testing.T) { client := getS3Client(t) bucketName := getNewBucketName() createBucket(t, client, bucketName) defer deleteBucket(t, client, bucketName) overwrite := "versioned overwrite" putObject(t, client, bucketName, "a.txt", "old") enableVersioning(t, client, bucketName) putObject(t, client, bucketName, "a.txt", overwrite) putObjectVersioned(t, client, bucketName, "b.txt") page, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{ Bucket: aws.String(bucketName), MaxKeys: aws.Int32(1), }) require.NoError(t, err) require.Len(t, page.Contents, 1) assert.Equal(t, "a.txt", *page.Contents[0].Key) assert.Equal(t, int64(len(overwrite)), *page.Contents[0].Size, "a page ending on the null object must still pick up the current version's metadata") } // A key such as "a.txt.bak" sorts between "a.txt" and "a.txt.versions", so the // sibling's outcome arrives entries later, possibly on a later page. func TestPreVersioningInterveningKeyRetraction(t *testing.T) { client := getS3Client(t) bucketName := getNewBucketName() createBucket(t, client, bucketName) defer deleteBucket(t, client, bucketName) putObject(t, client, bucketName, "a.txt", "pre-versioning") putObject(t, client, bucketName, "a.txt!between", "pre-versioning") putObject(t, client, bucketName, "a.txt.bak", "pre-versioning") enableVersioning(t, client, bucketName) deleteObject(t, client, bucketName, "a.txt") for _, maxKeys := range []int32{0, 1, 2} { assert.Equal(t, []string{"a.txt!between", "a.txt.bak"}, listAllKeys(t, client, bucketName, maxKeys), "maxKeys=%d must not list the deleted key", maxKeys) } } func TestPreVersioningInterveningKeyMetadata(t *testing.T) { client := getS3Client(t) bucketName := getNewBucketName() createBucket(t, client, bucketName) defer deleteBucket(t, client, bucketName) overwrite := "versioned overwrite" putObject(t, client, bucketName, "a.txt", "old") putObject(t, client, bucketName, "a.txt!between", "pre-versioning") putObject(t, client, bucketName, "a.txt.bak", "pre-versioning") enableVersioning(t, client, bucketName) putObject(t, client, bucketName, "a.txt", overwrite) for _, maxKeys := range []int32{1, 2} { var keys []string var token *string for { page, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{ Bucket: aws.String(bucketName), MaxKeys: aws.Int32(maxKeys), ContinuationToken: token, }) require.NoError(t, err) for _, o := range page.Contents { keys = append(keys, *o.Key) if *o.Key == "a.txt" { assert.Equal(t, int64(len(overwrite)), *o.Size, "maxKeys=%d must list the current version's metadata", maxKeys) } } if page.IsTruncated == nil || !*page.IsTruncated { break } token = page.NextContinuationToken } assert.Equal(t, []string{"a.txt", "a.txt!between", "a.txt.bak"}, keys, "maxKeys=%d", maxKeys) } } // CommonPrefixes derive from listable keys, so deleting the only pre-versioning // object under a prefix takes the prefix with it. func TestPreVersioningDeletedPrefixHidesCommonPrefix(t *testing.T) { client := getS3Client(t) bucketName := getNewBucketName() createBucket(t, client, bucketName) defer deleteBucket(t, client, bucketName) putObject(t, client, bucketName, "p/k.txt", "pre-versioning") enableVersioning(t, client, bucketName) deleteObject(t, client, bucketName, "p/k.txt") page, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{ Bucket: aws.String(bucketName), Delimiter: aws.String("/"), }) require.NoError(t, err) assert.Empty(t, page.Contents) assert.Empty(t, page.CommonPrefixes, "no listable key remains under p/") } // A non-slash delimiter derives prefixes straight from base-path keys, so a // delete-marked null object must take its prefix along; a live key under the // same prefix brings it back. func TestPreVersioningDeletedKeyHidesCustomDelimiterPrefix(t *testing.T) { client := getS3Client(t) bucketName := getNewBucketName() createBucket(t, client, bucketName) defer deleteBucket(t, client, bucketName) putObject(t, client, bucketName, "group-item", "pre-versioning") putObject(t, client, bucketName, "solo", "pre-versioning") enableVersioning(t, client, bucketName) deleteObject(t, client, bucketName, "group-item") listDashed := func() (keys, prefixes []string) { page, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{ Bucket: aws.String(bucketName), Delimiter: aws.String("-"), }) require.NoError(t, err) for _, o := range page.Contents { keys = append(keys, *o.Key) } for _, p := range page.CommonPrefixes { prefixes = append(prefixes, *p.Prefix) } return } keys, prefixes := listDashed() assert.Equal(t, []string{"solo"}, keys) assert.Empty(t, prefixes, "the deleted key was the prefix's only backer") putObject(t, client, bucketName, "group-live", "versioned") keys, prefixes = listDashed() assert.Equal(t, []string{"solo"}, keys) assert.Equal(t, []string{"group-"}, prefixes, "a live key restores the prefix") } // A delete marker for a version-only key (never a base object) must not debit // the backers of a prefix a live null object still stands behind. func TestVersionOnlyMarkerLeavesForeignPrefixAlone(t *testing.T) { client := getS3Client(t) bucketName := getNewBucketName() createBucket(t, client, bucketName) defer deleteBucket(t, client, bucketName) putObject(t, client, bucketName, "group-a", "pre-versioning") enableVersioning(t, client, bucketName) putObjectVersioned(t, client, bucketName, "group-b") deleteObject(t, client, bucketName, "group-b") page, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{ Bucket: aws.String(bucketName), Delimiter: aws.String("-"), }) require.NoError(t, err) assert.Empty(t, page.Contents) require.Len(t, page.CommonPrefixes, 1, "group-a still backs the prefix") assert.Equal(t, "group-", *page.CommonPrefixes[0].Prefix) } // Nested names (k, k!, k!!, ...) can hold more unresolved null objects than the // pending cap; the evicted key must still be settled, on any page size. func TestPreVersioningNestedNullObjectsBeyondCap(t *testing.T) { client := getS3Client(t) bucketName := getNewBucketName() createBucket(t, client, bucketName) defer deleteBucket(t, client, bucketName) var live []string for i := 0; i < 10; i++ { key := "k" + strings.Repeat("!", i) putObject(t, client, bucketName, key, "pre-versioning") if i > 0 { live = append(live, key) } } enableVersioning(t, client, bucketName) deleteObject(t, client, bucketName, "k") sort.Strings(live) for _, maxKeys := range []int32{0, 3, 9} { assert.Equal(t, live, listAllKeys(t, client, bucketName, maxKeys), "maxKeys=%d must not list the deleted key", maxKeys) } } // A suspended-versioning write is the current null version; its .versions // sibling (whose latest pointer the write cleared) must not list it a second // time or resurrect an older version's metadata. func TestSuspendedNullObjectListsOnce(t *testing.T) { client := getS3Client(t) bucketName := getNewBucketName() createBucket(t, client, bucketName) defer deleteBucket(t, client, bucketName) enableVersioning(t, client, bucketName) putObject(t, client, bucketName, "k.txt", "versioned") suspendVersioning(t, client, bucketName) current := "suspended current" putObject(t, client, bucketName, "k.txt", current) page, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{ Bucket: aws.String(bucketName), }) require.NoError(t, err) require.Len(t, page.Contents, 1, "one key must list exactly once") assert.Equal(t, int64(len(current)), *page.Contents[0].Size, "the null version is current") }