test: add directory marker Content-Type tests for #8712

Integration test (s3_list_empty_directory_test.go):
- Reproduces exact scenario from #8712: PutObject with explicit
  Content-Type, then ListObjects with prefix
- Covers ListV1/V2, with/without prefix, with/without delimiter
- Verifies mixed MIME types coexist in listings
- Asserts Size == 0 for all directory markers

Unit test (s3api_object_handlers_list_test.go):
- Tests doListFilerEntries with Mime set directly (pre-existing path)
- Tests Mime empty but ExtMimeType set in extended attributes
  (the actual #8712 scenario after filer strips application/octet-stream)
This commit is contained in:
Chris Lu
2026-03-21 13:23:27 -07:00
parent d3b8896094
commit fe9734a504
2 changed files with 197 additions and 0 deletions
@@ -223,6 +223,141 @@ func TestS3ListObjectsEmptyDirectoryMarkers(t *testing.T) {
})
}
// TestS3ListObjectsDirectoryMarkerWithContentType reproduces GitHub issue #8712:
// Directory markers created with an explicit Content-Type (e.g. application/octet-stream)
// must appear in ListObjects results, just like markers created without Content-Type.
func TestS3ListObjectsDirectoryMarkerWithContentType(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
cluster, err := startMiniCluster(t)
require.NoError(t, err)
defer cluster.Stop()
bucketName := createTestBucket(t, cluster, "test-content-type-dirs-")
// Create a directory marker with explicit Content-Type (the scenario from #8712)
_, err = cluster.s3Client.PutObject(&s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String("test-content/empty/"),
Body: bytes.NewReader([]byte{}),
ContentType: aws.String("application/octet-stream"),
})
require.NoError(t, err, "failed to create directory marker with content-type")
// Verify the directory marker exists via HeadObject
headResp, err := cluster.s3Client.HeadObject(&s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String("test-content/empty/"),
})
require.NoError(t, err, "directory marker should exist via HeadObject")
assert.Equal(t, "application/octet-stream", aws.StringValue(headResp.ContentType),
"Content-Type should be preserved")
// Test 1: ListObjectsV2 with prefix (no delimiter) — the exact scenario from #8712
t.Run("ListV2_WithPrefix_NoDelimiter", func(t *testing.T) {
resp, err := cluster.s3Client.ListObjectsV2(&s3.ListObjectsV2Input{
Bucket: aws.String(bucketName),
Prefix: aws.String("test-content"),
})
require.NoError(t, err)
keys := collectKeys(resp.Contents)
assert.Equal(t, []string{"test-content/empty/"}, keys,
"directory marker with explicit Content-Type should be listed")
// Verify the directory marker has zero size
require.Equal(t, 1, len(resp.Contents), "should have exactly one object in Contents")
assert.Equal(t, int64(0), aws.Int64Value(resp.Contents[0].Size),
"directory marker should have Size == 0")
})
// Test 2: ListObjectsV1 with prefix
t.Run("ListV1_WithPrefix_NoDelimiter", func(t *testing.T) {
resp, err := cluster.s3Client.ListObjects(&s3.ListObjectsInput{
Bucket: aws.String(bucketName),
Prefix: aws.String("test-content"),
})
require.NoError(t, err)
keys := collectKeysV1(resp.Contents)
assert.Equal(t, []string{"test-content/empty/"}, keys,
"directory marker with explicit Content-Type should be listed")
// Verify the directory marker has zero size
require.Equal(t, 1, len(resp.Contents), "should have exactly one object in Contents")
assert.Equal(t, int64(0), aws.Int64Value(resp.Contents[0].Size),
"directory marker should have Size == 0")
})
// Test 3: Without prefix
t.Run("ListV2_NoPrefix", func(t *testing.T) {
resp, err := cluster.s3Client.ListObjectsV2(&s3.ListObjectsV2Input{
Bucket: aws.String(bucketName),
})
require.NoError(t, err)
keys := collectKeys(resp.Contents)
assert.Contains(t, keys, "test-content/empty/",
"directory marker with explicit Content-Type should appear in full listing")
// Find and verify the directory marker has zero size
var dirMarker *s3.Object
for _, obj := range resp.Contents {
if aws.StringValue(obj.Key) == "test-content/empty/" {
dirMarker = obj
break
}
}
require.NotNil(t, dirMarker, "directory marker should be in Contents")
assert.Equal(t, int64(0), aws.Int64Value(dirMarker.Size),
"directory marker should have Size == 0")
})
// Test 4: With delimiter — should appear as CommonPrefix
t.Run("ListV2_WithDelimiter", func(t *testing.T) {
resp, err := cluster.s3Client.ListObjectsV2(&s3.ListObjectsV2Input{
Bucket: aws.String(bucketName),
Delimiter: aws.String("/"),
})
require.NoError(t, err)
prefixes := collectPrefixes(resp.CommonPrefixes)
assert.Contains(t, prefixes, "test-content/",
"parent of directory marker should appear as CommonPrefix")
})
// Test 5: Alongside a default-MIME directory marker — both should be listed
t.Run("ListV2_MixedMimeTypes", func(t *testing.T) {
_, err := cluster.s3Client.PutObject(&s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String("test-content/default/"),
Body: bytes.NewReader([]byte{}),
// No ContentType — defaults to httpd/unix-directory
})
require.NoError(t, err)
resp, err := cluster.s3Client.ListObjectsV2(&s3.ListObjectsV2Input{
Bucket: aws.String(bucketName),
Prefix: aws.String("test-content"),
})
require.NoError(t, err)
keys := collectKeys(resp.Contents)
sort.Strings(keys)
assert.Equal(t, []string{"test-content/default/", "test-content/empty/"}, keys,
"both directory markers (custom and default MIME) should be listed")
// Verify both directory markers have zero size
require.Equal(t, 2, len(resp.Contents), "should have exactly two objects in Contents")
for _, obj := range resp.Contents {
assert.Equal(t, int64(0), aws.Int64Value(obj.Size),
"directory marker %s should have Size == 0", aws.StringValue(obj.Key))
}
})
}
func collectKeys(contents []*s3.Object) []string {
keys := make([]string, 0, len(contents))
for _, obj := range contents {
@@ -9,6 +9,7 @@ import (
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/stretchr/testify/assert"
grpc "google.golang.org/grpc"
@@ -284,6 +285,67 @@ func TestAllowUnorderedParameterValidation(t *testing.T) {
})
}
// TestDoListFilerEntries_DirectoryKeyObjectWithCustomMimeType is a regression test
// for GitHub issue #8712: directory markers created via PutObject with an explicit
// Content-Type (e.g., "application/octet-stream") must appear in ListObjects results.
// Only the default "httpd/unix-directory" MIME was being listed before the fix.
func TestDoListFilerEntries_DirectoryKeyObjectWithCustomMimeType(t *testing.T) {
s3a := &S3ApiServer{
option: &S3ApiServerOption{
BucketsPath: "/buckets",
},
}
tests := []struct {
name string
entry *filer_pb.Entry
}{
{"default FolderMimeType", &filer_pb.Entry{
Name: "empty", IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{Mime: "httpd/unix-directory"},
}},
{"Mime set to application/octet-stream", &filer_pb.Entry{
Name: "empty", IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{Mime: "application/octet-stream"},
}},
{"custom MIME", &filer_pb.Entry{
Name: "empty", IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{Mime: "application/x-directory"},
}},
// The real-world scenario for #8712: the filer drops Mime on directories,
// so only the ExtMimeType extended attribute survives.
{"Mime empty but ExtMimeType set", &filer_pb.Entry{
Name: "empty", IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{},
Extended: map[string][]byte{s3_constants.ExtMimeType: []byte("application/octet-stream")},
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := &testFilerClient{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/bucket": {
{Name: "test-content", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}},
},
"/buckets/bucket/test-content": {tt.entry},
"/buckets/bucket/test-content/empty": {},
},
}
cursor := &ListingCursor{maxKeys: 1000}
var seen []string
_, err := s3a.doListFilerEntries(client, "/buckets/bucket", "test-content", cursor, "", "", false, "bucket", func(dir string, entry *filer_pb.Entry) {
seen = append(seen, entry.Name)
})
assert.NoError(t, err)
assert.Contains(t, seen, "empty",
"directory key object should be listed for case %q", tt.name)
})
}
}
func TestDoListFilerEntries_BucketRootPrefixSlashDelimiterSlash_ListsDirectories(t *testing.T) {
// Regression test for a bug where doListFilerEntries returned early when
// prefix == "/" && delimiter == "/", causing bucket-root folder listings