test: compare ListObjects and ListObjectVersions over the same namespace (#10515)

* test: compare ListObjects and ListObjectVersions over the same namespace

The two listings walk the same tree through separate code paths, so a client
navigating by versioned listings can see a different namespace than one
navigating by plain listings, and concludes keys are missing that are plainly
there. Testing each path on its own never catches that; only comparing them
does, and nothing compared them.

Asserts both report identical current keys and identical common prefixes
across a backup-shaped tree: nested prefixes, a prefix naming an object
exactly, a key that is simultaneously an object and the parent of other keys,
a partial key fragment, and a prefix matching nothing.

The version view is reduced to what a plain listing reports — latest versions
that are not delete markers — so the comparison is like for like.

* test: guard against truncated pages and cover the delete-marker path
This commit is contained in:
Chris Lu
2026-07-31 19:46:31 -07:00
committed by GitHub
parent fce4da5c9c
commit 910fa1ff37
@@ -0,0 +1,270 @@
package s3api
import (
"bytes"
"context"
"sort"
"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"
)
// ListObjects and ListObjectVersions walk the same namespace and must describe it
// the same way. They are separate code paths, so a client that navigates by
// versioned listings can see a different tree than one navigating by plain
// listings — and concludes keys are missing that are plainly there. Testing the
// two paths independently never catches that; only comparing them does.
// listingParityTree is shaped after a backup repository: nested prefixes, a file
// beside a prefix at the same level, and a key that is both an object and the
// parent of other keys.
var listingParityTree = []string{
"backup/archive/001/data.blk",
"backup/archive/002/data.blk",
"backup/archive/summary.xml",
"backup/meta",
"backup/meta/nested.txt",
"backup/index",
}
func currentKeysAndPrefixes(t *testing.T, client *s3.Client, bucket, prefix, delimiter string) ([]string, []string) {
t.Helper()
in := &s3.ListObjectsV2Input{Bucket: aws.String(bucket)}
if prefix != "" {
in.Prefix = aws.String(prefix)
}
if delimiter != "" {
in.Delimiter = aws.String(delimiter)
}
resp, err := client.ListObjectsV2(context.TODO(), in)
require.NoError(t, err)
// One page only. A truncated response would silently compare partial results
// against partial results and report parity that was never checked.
require.False(t, aws.ToBool(resp.IsTruncated), "test data must fit one page")
var keys, prefixes []string
for _, o := range resp.Contents {
require.NotNil(t, o.Key)
keys = append(keys, *o.Key)
}
for _, p := range resp.CommonPrefixes {
require.NotNil(t, p.Prefix)
prefixes = append(prefixes, *p.Prefix)
}
sort.Strings(keys)
sort.Strings(prefixes)
return keys, prefixes
}
func currentKeysAndPrefixesFromVersions(t *testing.T, client *s3.Client, bucket, prefix, delimiter string) ([]string, []string) {
t.Helper()
in := &s3.ListObjectVersionsInput{Bucket: aws.String(bucket)}
if prefix != "" {
in.Prefix = aws.String(prefix)
}
if delimiter != "" {
in.Delimiter = aws.String(delimiter)
}
resp, err := client.ListObjectVersions(context.TODO(), in)
require.NoError(t, err)
require.False(t, aws.ToBool(resp.IsTruncated), "test data must fit one page")
// Reduce the version view to the same thing ListObjects reports: keys whose
// current version is real content, not a delete marker.
//
// Delete markers are only visible here as individual entries. Under a
// delimiter, keys grouped into a common prefix do not appear individually, so
// this cannot tell that every key beneath a prefix is delete-marked — such a
// prefix stays in the returned list. Comparisons that need that case must
// query the prefix directly rather than relying on the grouped view.
deleted := make(map[string]bool)
for _, m := range resp.DeleteMarkers {
if m.Key != nil && m.IsLatest != nil && *m.IsLatest {
deleted[*m.Key] = true
}
}
var keys, prefixes []string
for _, v := range resp.Versions {
if v.Key == nil || v.IsLatest == nil || !*v.IsLatest {
continue
}
if deleted[*v.Key] {
continue
}
keys = append(keys, *v.Key)
}
for _, p := range resp.CommonPrefixes {
require.NotNil(t, p.Prefix)
prefixes = append(prefixes, *p.Prefix)
}
sort.Strings(keys)
sort.Strings(prefixes)
return keys, prefixes
}
func TestListingParityAcrossPrefixesAndDelimiter(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucket(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
enableVersioning(t, client, bucketName)
for _, key := range listingParityTree {
_, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
Body: bytes.NewReader([]byte("x")),
})
require.NoError(t, err)
}
cases := []struct {
name string
prefix string
delimiter string
}{
{"root delimited", "", "/"},
{"root undelimited", "", ""},
{"one level delimited", "backup/", "/"},
{"one level undelimited", "backup/", ""},
{"two levels delimited", "backup/archive/", "/"},
{"leaf prefix delimited", "backup/archive/001/", "/"},
// The prefix names an object exactly. Both listings must return that
// object and must not descend into anything below it.
{"prefix is exactly an object", "backup/archive/summary.xml", "/"},
{"prefix is exactly an object undelimited", "backup/archive/summary.xml", ""},
// A key that is also a parent: "backup/meta" is an object and
// "backup/meta/" is a prefix. The two listings must not disagree about
// which of those the client is looking at.
{"object that is also a prefix", "backup/meta", "/"},
{"prefix form of that object", "backup/meta/", "/"},
{"partial key fragment", "backup/ind", "/"},
{"prefix matching nothing", "backup/nothing/", "/"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
plainKeys, plainPrefixes := currentKeysAndPrefixes(t, client, bucketName, tc.prefix, tc.delimiter)
versionKeys, versionPrefixes := currentKeysAndPrefixesFromVersions(t, client, bucketName, tc.prefix, tc.delimiter)
assert.Equal(t, plainKeys, versionKeys,
"prefix=%q delimiter=%q: ListObjectVersions reports a different set of current keys than ListObjects", tc.prefix, tc.delimiter)
assert.Equal(t, plainPrefixes, versionPrefixes,
"prefix=%q delimiter=%q: ListObjectVersions reports different common prefixes than ListObjects", tc.prefix, tc.delimiter)
})
}
}
// Deleting every key under a prefix must retire the prefix from both listings
// together. A prefix that lingers in one view sends a client looking for keys the
// other view says are gone.
func TestListingParityAfterPrefixEmptied(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucket(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
enableVersioning(t, client, bucketName)
keys := []string{"backup/archive/001/data.blk", "backup/archive/002/data.blk"}
versionIDs := make([]string, 0, len(keys))
for _, key := range keys {
put, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
Body: bytes.NewReader([]byte("x")),
})
require.NoError(t, err)
require.NotNil(t, put.VersionId)
versionIDs = append(versionIDs, *put.VersionId)
}
plainKeys, plainPrefixes := currentKeysAndPrefixes(t, client, bucketName, "backup/archive/", "/")
versionKeys, versionPrefixes := currentKeysAndPrefixesFromVersions(t, client, bucketName, "backup/archive/", "/")
require.Equal(t, plainKeys, versionKeys)
require.Equal(t, plainPrefixes, versionPrefixes)
require.Len(t, plainPrefixes, 2, "both leaf prefixes should be visible while populated")
// Remove the versions outright rather than layering delete markers, so the
// containers are left genuinely empty.
for i, key := range keys {
_, err := client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
VersionId: aws.String(versionIDs[i]),
})
require.NoError(t, err)
}
plainKeys, plainPrefixes = currentKeysAndPrefixes(t, client, bucketName, "backup/archive/", "/")
versionKeys, versionPrefixes = currentKeysAndPrefixesFromVersions(t, client, bucketName, "backup/archive/", "/")
// Both views must still describe the same namespace. Whether an emptied
// prefix is retired promptly is a separate question from whether the two
// listings agree about it, and only the latter is under test here.
assert.Equal(t, plainKeys, versionKeys, "listings disagree on remaining keys after the prefix was emptied")
assert.Equal(t, plainPrefixes, versionPrefixes, "listings disagree on remaining common prefixes after the prefix was emptied")
// Asserted on both sides rather than leaning on the equality above to catch a
// divergence in whichever direction it happens.
assert.Empty(t, plainKeys, "no current keys should remain once every version is gone")
assert.Empty(t, versionKeys, "no current keys should remain once every version is gone")
}
// The other way a key stops being current: a delete marker rather than removal of
// the version. Both listings must drop the key while its history stays intact.
func TestListingParityAfterDeleteMarker(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucket(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
enableVersioning(t, client, bucketName)
const marked = "backup/archive/001/data.blk"
const kept = "backup/archive/002/data.blk"
for _, key := range []string{marked, kept} {
_, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
Body: bytes.NewReader([]byte("x")),
})
require.NoError(t, err)
}
// No version id: this layers a delete marker instead of removing the version.
_, err := client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(marked),
})
require.NoError(t, err)
for _, tc := range []struct{ prefix, delimiter string }{
{"backup/archive/", ""},
{"backup/archive/001/", "/"},
{"backup/archive/001/", ""},
} {
t.Run(tc.prefix+"|"+tc.delimiter, func(t *testing.T) {
plainKeys, plainPrefixes := currentKeysAndPrefixes(t, client, bucketName, tc.prefix, tc.delimiter)
versionKeys, versionPrefixes := currentKeysAndPrefixesFromVersions(t, client, bucketName, tc.prefix, tc.delimiter)
assert.Equal(t, plainKeys, versionKeys,
"prefix=%q delimiter=%q: listings disagree on current keys after a delete marker", tc.prefix, tc.delimiter)
assert.Equal(t, plainPrefixes, versionPrefixes,
"prefix=%q delimiter=%q: listings disagree on common prefixes after a delete marker", tc.prefix, tc.delimiter)
assert.NotContains(t, plainKeys, marked, "a delete-marked key is not current")
})
}
// The history the marker hides must still be there.
versions, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{
Bucket: aws.String(bucketName),
Prefix: aws.String(marked),
})
require.NoError(t, err)
assert.Len(t, versions.Versions, 1, "the superseded version must survive its delete marker")
assert.Len(t, versions.DeleteMarkers, 1)
}