You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

665 lines
20 KiB

package s3api
import (
"encoding/json"
"encoding/xml"
"fmt"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go/service/s3"
"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"
"github.com/stretchr/testify/require"
)
func TestPutBucketAclCannedAclSupport(t *testing.T) {
// Test that the ExtractAcl function can handle various canned ACLs
// This tests the core functionality without requiring a fully initialized S3ApiServer
testCases := []struct {
name string
cannedAcl string
shouldWork bool
description string
}{
{
name: "private",
cannedAcl: s3_constants.CannedAclPrivate,
shouldWork: true,
description: "private ACL should be accepted",
},
{
name: "public-read",
cannedAcl: s3_constants.CannedAclPublicRead,
shouldWork: true,
description: "public-read ACL should be accepted",
},
{
name: "public-read-write",
cannedAcl: s3_constants.CannedAclPublicReadWrite,
shouldWork: true,
description: "public-read-write ACL should be accepted",
},
{
name: "authenticated-read",
cannedAcl: s3_constants.CannedAclAuthenticatedRead,
shouldWork: true,
description: "authenticated-read ACL should be accepted",
},
{
name: "bucket-owner-read",
cannedAcl: s3_constants.CannedAclBucketOwnerRead,
shouldWork: true,
description: "bucket-owner-read ACL should be accepted",
},
{
name: "bucket-owner-full-control",
cannedAcl: s3_constants.CannedAclBucketOwnerFullControl,
shouldWork: true,
description: "bucket-owner-full-control ACL should be accepted",
},
{
name: "invalid-acl",
cannedAcl: "invalid-acl-value",
shouldWork: false,
description: "invalid ACL should be rejected",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create a request with the specified canned ACL
req := httptest.NewRequest("PUT", "/bucket?acl", nil)
req.Header.Set(s3_constants.AmzCannedAcl, tc.cannedAcl)
req.Header.Set(s3_constants.AmzAccountId, "test-account-123")
// Create a mock IAM for testing
mockIam := &mockIamInterface{}
// Test the ACL extraction directly
grants, errCode := ExtractAcl(req, mockIam, "", "test-account-123", "test-account-123", "test-account-123")
if tc.shouldWork {
assert.Equal(t, s3err.ErrNone, errCode, "Expected ACL parsing to succeed for %s", tc.cannedAcl)
assert.NotEmpty(t, grants, "Expected grants to be generated for valid ACL %s", tc.cannedAcl)
t.Logf("✓ PASS: %s - %s", tc.name, tc.description)
} else {
assert.NotEqual(t, s3err.ErrNone, errCode, "Expected ACL parsing to fail for invalid ACL %s", tc.cannedAcl)
t.Logf("✓ PASS: %s - %s", tc.name, tc.description)
}
})
}
}
// TestBucketWithoutACLIsNotPublicRead tests that buckets without ACLs are not public-read
func TestBucketWithoutACLIsNotPublicRead(t *testing.T) {
// Create a bucket config without ACL (like a freshly created bucket)
config := &BucketConfig{
Name: "test-bucket",
IsPublicRead: false, // Should be explicitly false
}
// Verify that buckets without ACL are not public-read
assert.False(t, config.IsPublicRead, "Bucket without ACL should not be public-read")
}
func TestBucketConfigInitialization(t *testing.T) {
// Test that BucketConfig properly initializes IsPublicRead field
config := &BucketConfig{
Name: "test-bucket",
IsPublicRead: false, // Explicitly set to false for private buckets
}
// Verify proper initialization
assert.False(t, config.IsPublicRead, "Newly created bucket should not be public-read by default")
}
// TestUpdateBucketConfigCacheConsistency tests that updateBucketConfigCacheFromEntry
// properly handles the IsPublicRead flag consistently with getBucketConfig
func TestUpdateBucketConfigCacheConsistency(t *testing.T) {
t.Run("bucket without ACL should have IsPublicRead=false", func(t *testing.T) {
// Simulate an entry without ACL (like a freshly created bucket)
entry := &filer_pb.Entry{
Name: "test-bucket",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0755,
},
// Extended is nil or doesn't contain ACL
}
// Test what updateBucketConfigCacheFromEntry would create
config := &BucketConfig{
Name: entry.Name,
Entry: entry,
IsPublicRead: false, // Should be explicitly false
}
// When Extended is nil, IsPublicRead should be false
assert.False(t, config.IsPublicRead, "Bucket without Extended metadata should not be public-read")
// When Extended exists but has no ACL key, IsPublicRead should also be false
entry.Extended = make(map[string][]byte)
entry.Extended["some-other-key"] = []byte("some-value")
config = &BucketConfig{
Name: entry.Name,
Entry: entry,
IsPublicRead: false, // Should be explicitly false
}
// Simulate the else branch: no ACL means private bucket
if _, exists := entry.Extended[s3_constants.ExtAmzAclKey]; !exists {
config.IsPublicRead = false
}
assert.False(t, config.IsPublicRead, "Bucket with Extended but no ACL should not be public-read")
})
t.Run("bucket with public-read ACL should have IsPublicRead=true", func(t *testing.T) {
// Create a mock public-read ACL using AWS S3 SDK types
publicReadGrants := []*s3.Grant{
{
Grantee: &s3.Grantee{
Type: &s3_constants.GrantTypeGroup,
URI: &s3_constants.GranteeGroupAllUsers,
},
Permission: &s3_constants.PermissionRead,
},
}
aclBytes, err := json.Marshal(publicReadGrants)
require.NoError(t, err)
entry := &filer_pb.Entry{
Name: "public-bucket",
Extended: map[string][]byte{
s3_constants.ExtAmzAclKey: aclBytes,
},
}
config := &BucketConfig{
Name: entry.Name,
Entry: entry,
IsPublicRead: false, // Start with false
}
// Simulate what updateBucketConfigCacheFromEntry would do
if acl, exists := entry.Extended[s3_constants.ExtAmzAclKey]; exists {
config.ACL = acl
config.IsPublicRead = parseAndCachePublicReadStatus(acl)
}
assert.True(t, config.IsPublicRead, "Bucket with public-read ACL should be public-read")
})
}
// mockIamInterface is a simple mock for testing
type mockIamInterface struct{}
func (m *mockIamInterface) GetAccountNameById(canonicalId string) string {
return "test-user-" + canonicalId
}
func (m *mockIamInterface) GetAccountIdByEmail(email string) string {
return "account-for-" + email
}
// TestListAllMyBucketsResultNamespace verifies that the ListAllMyBucketsResult
// XML response includes the proper S3 namespace URI
func TestListAllMyBucketsResultNamespace(t *testing.T) {
// Create a sample ListAllMyBucketsResult response
response := ListAllMyBucketsResult{
Owner: CanonicalUser{
ID: "test-owner-id",
DisplayName: "test-owner",
},
Buckets: ListAllMyBucketsList{
Bucket: []ListAllMyBucketsEntry{
{
Name: "test-bucket",
CreationDate: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
},
},
},
}
// Marshal the response to XML
xmlData, err := xml.Marshal(response)
require.NoError(t, err, "Failed to marshal XML response")
xmlString := string(xmlData)
// Verify that the XML contains the proper namespace
assert.Contains(t, xmlString, `xmlns="http://s3.amazonaws.com/doc/2006-03-01/"`,
"XML response should contain the S3 namespace URI")
// Verify the root element has the correct name
assert.Contains(t, xmlString, "<ListAllMyBucketsResult",
"XML response should have ListAllMyBucketsResult root element")
// Verify structure contains expected elements
assert.Contains(t, xmlString, "<Owner>", "XML should contain Owner element")
assert.Contains(t, xmlString, "<Buckets>", "XML should contain Buckets element")
assert.Contains(t, xmlString, "<Bucket>", "XML should contain Bucket element")
assert.Contains(t, xmlString, "<Name>test-bucket</Name>", "XML should contain bucket name")
t.Logf("Generated XML:\n%s", xmlString)
}
// TestListBucketsOwnershipFiltering tests that ListBucketsHandler properly filters
// buckets based on ownership, allowing only bucket owners (or admins) to see their buckets
func TestListBucketsOwnershipFiltering(t *testing.T) {
testCases := []struct {
name string
buckets []testBucket
requestIdentityId string
requestIsAdmin bool
expectedBucketNames []string
description string
}{
{
name: "non-admin sees only owned buckets",
buckets: []testBucket{
{name: "user1-bucket", ownerId: "user1"},
{name: "user2-bucket", ownerId: "user2"},
{name: "user1-bucket2", ownerId: "user1"},
},
requestIdentityId: "user1",
requestIsAdmin: false,
expectedBucketNames: []string{"user1-bucket", "user1-bucket2"},
description: "Non-admin user should only see buckets they own",
},
{
name: "admin sees all buckets",
buckets: []testBucket{
{name: "user1-bucket", ownerId: "user1"},
{name: "user2-bucket", ownerId: "user2"},
{name: "user3-bucket", ownerId: "user3"},
},
requestIdentityId: "admin",
requestIsAdmin: true,
expectedBucketNames: []string{"user1-bucket", "user2-bucket", "user3-bucket"},
description: "Admin should see all buckets regardless of owner",
},
{
name: "buckets without owner are hidden from non-admins",
buckets: []testBucket{
{name: "owned-bucket", ownerId: "user1"},
{name: "unowned-bucket", ownerId: ""}, // No owner set
},
requestIdentityId: "user2",
requestIsAdmin: false,
expectedBucketNames: []string{},
description: "Buckets without owner should be hidden from non-admin users",
},
{
name: "unauthenticated user sees no buckets",
buckets: []testBucket{
{name: "owned-bucket", ownerId: "user1"},
{name: "unowned-bucket", ownerId: ""},
},
requestIdentityId: "",
requestIsAdmin: false,
expectedBucketNames: []string{},
description: "Unauthenticated requests should not see any buckets",
},
{
name: "admin sees buckets regardless of ownership",
buckets: []testBucket{
{name: "user1-bucket", ownerId: "user1"},
{name: "user2-bucket", ownerId: "user2"},
{name: "unowned-bucket", ownerId: ""},
},
requestIdentityId: "admin",
requestIsAdmin: true,
expectedBucketNames: []string{"user1-bucket", "user2-bucket", "unowned-bucket"},
description: "Admin should see all buckets regardless of ownership",
},
{
name: "buckets with nil Extended metadata hidden from non-admins",
buckets: []testBucket{
{name: "bucket-no-extended", ownerId: "", nilExtended: true},
{name: "bucket-with-owner", ownerId: "user1"},
},
requestIdentityId: "user1",
requestIsAdmin: false,
expectedBucketNames: []string{"bucket-with-owner"},
description: "Buckets with nil Extended (no owner) should be hidden from non-admins",
},
{
name: "user sees only their bucket among many",
buckets: []testBucket{
{name: "alice-bucket", ownerId: "alice"},
{name: "bob-bucket", ownerId: "bob"},
{name: "charlie-bucket", ownerId: "charlie"},
{name: "alice-bucket2", ownerId: "alice"},
},
requestIdentityId: "bob",
requestIsAdmin: false,
expectedBucketNames: []string{"bob-bucket"},
description: "User should see only their single bucket among many",
},
{
name: "admin sees buckets without owners",
buckets: []testBucket{
{name: "owned-bucket", ownerId: "user1"},
{name: "unowned-bucket", ownerId: ""},
{name: "no-metadata-bucket", ownerId: "", nilExtended: true},
},
requestIdentityId: "admin",
requestIsAdmin: true,
expectedBucketNames: []string{"owned-bucket", "unowned-bucket", "no-metadata-bucket"},
description: "Admin should see all buckets including those without owners",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create mock entries
entries := make([]*filer_pb.Entry, 0, len(tc.buckets))
for _, bucket := range tc.buckets {
entry := &filer_pb.Entry{
Name: bucket.name,
IsDirectory: true,
Attributes: &filer_pb.FuseAttributes{
Crtime: time.Now().Unix(),
},
}
if !bucket.nilExtended {
entry.Extended = make(map[string][]byte)
if bucket.ownerId != "" {
entry.Extended[s3_constants.AmzIdentityId] = []byte(bucket.ownerId)
}
}
entries = append(entries, entry)
}
// Filter entries using the actual production code
var filteredBuckets []string
for _, entry := range entries {
var identity *Identity
if tc.requestIdentityId != "" {
identity = mockIdentity(tc.requestIdentityId, tc.requestIsAdmin)
}
if isBucketVisibleToIdentity(entry, identity) {
filteredBuckets = append(filteredBuckets, entry.Name)
}
}
// Assert expected buckets match filtered buckets
assert.ElementsMatch(t, tc.expectedBucketNames, filteredBuckets,
"%s - Expected buckets: %v, Got: %v", tc.description, tc.expectedBucketNames, filteredBuckets)
})
}
}
// testBucket represents a bucket for testing with ownership metadata
type testBucket struct {
name string
ownerId string
nilExtended bool
}
// mockIdentity creates a mock Identity for testing bucket visibility
func mockIdentity(name string, isAdmin bool) *Identity {
identity := &Identity{
Name: name,
}
if isAdmin {
identity.Credentials = []*Credential{
{
AccessKey: "admin-key",
SecretKey: "admin-secret",
},
}
identity.Actions = []Action{Action(s3_constants.ACTION_ADMIN)}
}
return identity
}
// TestListBucketsOwnershipEdgeCases tests edge cases in ownership filtering
func TestListBucketsOwnershipEdgeCases(t *testing.T) {
t.Run("malformed owner id with special characters", func(t *testing.T) {
entry := &filer_pb.Entry{
Name: "test-bucket",
IsDirectory: true,
Extended: map[string][]byte{
s3_constants.AmzIdentityId: []byte("user@domain.com"),
},
Attributes: &filer_pb.FuseAttributes{
Crtime: time.Now().Unix(),
},
}
identity := mockIdentity("user@domain.com", false)
// Should match exactly even with special characters
isVisible := isBucketVisibleToIdentity(entry, identity)
assert.True(t, isVisible, "Should match owner ID with special characters exactly")
})
t.Run("owner id with unicode characters", func(t *testing.T) {
unicodeOwnerId := "用户123"
entry := &filer_pb.Entry{
Name: "test-bucket",
IsDirectory: true,
Extended: map[string][]byte{
s3_constants.AmzIdentityId: []byte(unicodeOwnerId),
},
Attributes: &filer_pb.FuseAttributes{
Crtime: time.Now().Unix(),
},
}
identity := mockIdentity(unicodeOwnerId, false)
isVisible := isBucketVisibleToIdentity(entry, identity)
assert.True(t, isVisible, "Should handle unicode owner IDs correctly")
})
t.Run("owner id with binary data", func(t *testing.T) {
entry := &filer_pb.Entry{
Name: "test-bucket",
IsDirectory: true,
Extended: map[string][]byte{
s3_constants.AmzIdentityId: []byte{0x00, 0x01, 0x02, 0xFF},
},
Attributes: &filer_pb.FuseAttributes{
Crtime: time.Now().Unix(),
},
}
identity := mockIdentity("normaluser", false)
// Should not panic when converting binary data to string
assert.NotPanics(t, func() {
isVisible := isBucketVisibleToIdentity(entry, identity)
assert.False(t, isVisible, "Binary owner ID should not match normal user")
})
})
t.Run("empty owner id in Extended", func(t *testing.T) {
entry := &filer_pb.Entry{
Name: "test-bucket",
IsDirectory: true,
Extended: map[string][]byte{
s3_constants.AmzIdentityId: []byte(""),
},
Attributes: &filer_pb.FuseAttributes{
Crtime: time.Now().Unix(),
},
}
identity := mockIdentity("user1", false)
isVisible := isBucketVisibleToIdentity(entry, identity)
assert.False(t, isVisible, "Empty owner ID should be treated as unowned (hidden from non-admins)")
})
t.Run("nil Extended map safe access", func(t *testing.T) {
entry := &filer_pb.Entry{
Name: "test-bucket",
IsDirectory: true,
Extended: nil, // Explicitly nil
Attributes: &filer_pb.FuseAttributes{
Crtime: time.Now().Unix(),
},
}
identity := mockIdentity("user1", false)
// Should not panic with nil Extended map
assert.NotPanics(t, func() {
isVisible := isBucketVisibleToIdentity(entry, identity)
assert.False(t, isVisible, "Nil Extended (no owner) should be hidden from non-admins")
})
})
t.Run("very long owner id", func(t *testing.T) {
longOwnerId := strings.Repeat("a", 10000)
entry := &filer_pb.Entry{
Name: "test-bucket",
IsDirectory: true,
Extended: map[string][]byte{
s3_constants.AmzIdentityId: []byte(longOwnerId),
},
Attributes: &filer_pb.FuseAttributes{
Crtime: time.Now().Unix(),
},
}
identity := mockIdentity(longOwnerId, false)
// Should handle very long owner IDs without panic
assert.NotPanics(t, func() {
isVisible := isBucketVisibleToIdentity(entry, identity)
assert.True(t, isVisible, "Long owner ID should match correctly")
})
})
}
// TestListBucketsOwnershipWithPermissions tests that ownership filtering
// works in conjunction with permission checks
func TestListBucketsOwnershipWithPermissions(t *testing.T) {
t.Run("ownership check before permission check", func(t *testing.T) {
// Simulate scenario where ownership check filters first,
// then permission check applies to remaining buckets
entries := []*filer_pb.Entry{
{
Name: "owned-bucket",
IsDirectory: true,
Extended: map[string][]byte{
s3_constants.AmzIdentityId: []byte("user1"),
},
Attributes: &filer_pb.FuseAttributes{Crtime: time.Now().Unix()},
},
{
Name: "other-bucket",
IsDirectory: true,
Extended: map[string][]byte{
s3_constants.AmzIdentityId: []byte("user2"),
},
Attributes: &filer_pb.FuseAttributes{Crtime: time.Now().Unix()},
},
}
identity := mockIdentity("user1", false)
// First pass: ownership filtering
var afterOwnershipFilter []*filer_pb.Entry
for _, entry := range entries {
if isBucketVisibleToIdentity(entry, identity) {
afterOwnershipFilter = append(afterOwnershipFilter, entry)
}
}
// Only owned-bucket should remain after ownership filter
assert.Len(t, afterOwnershipFilter, 1, "Only owned bucket should pass ownership filter")
assert.Equal(t, "owned-bucket", afterOwnershipFilter[0].Name)
// Permission checks would apply to afterOwnershipFilter entries
// (not tested here as it depends on IAM system)
})
t.Run("admin bypasses ownership but not permissions", func(t *testing.T) {
entries := []*filer_pb.Entry{
{
Name: "user1-bucket",
IsDirectory: true,
Extended: map[string][]byte{
s3_constants.AmzIdentityId: []byte("user1"),
},
Attributes: &filer_pb.FuseAttributes{Crtime: time.Now().Unix()},
},
{
Name: "user2-bucket",
IsDirectory: true,
Extended: map[string][]byte{
s3_constants.AmzIdentityId: []byte("user2"),
},
Attributes: &filer_pb.FuseAttributes{Crtime: time.Now().Unix()},
},
}
identity := mockIdentity("admin-user", true)
// Admin bypasses ownership check
var afterOwnershipFilter []*filer_pb.Entry
for _, entry := range entries {
if isBucketVisibleToIdentity(entry, identity) {
afterOwnershipFilter = append(afterOwnershipFilter, entry)
}
}
// Admin should see all buckets after ownership filter
assert.Len(t, afterOwnershipFilter, 2, "Admin should see all buckets after ownership filter")
// Note: Permission checks still apply to admins in actual implementation
})
}
// TestListBucketsOwnershipCaseSensitivity tests case sensitivity in owner matching
func TestListBucketsOwnershipCaseSensitivity(t *testing.T) {
entry := &filer_pb.Entry{
Name: "test-bucket",
IsDirectory: true,
Extended: map[string][]byte{
s3_constants.AmzIdentityId: []byte("User1"),
},
Attributes: &filer_pb.FuseAttributes{
Crtime: time.Now().Unix(),
},
}
testCases := []struct {
requestIdentityId string
shouldMatch bool
}{
{"User1", true},
{"user1", false}, // Case sensitive
{"USER1", false}, // Case sensitive
{"User2", false},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("identity_%s", tc.requestIdentityId), func(t *testing.T) {
identity := mockIdentity(tc.requestIdentityId, false)
isVisible := isBucketVisibleToIdentity(entry, identity)
if tc.shouldMatch {
assert.True(t, isVisible, "Identity %s should match (case sensitive)", tc.requestIdentityId)
} else {
assert.False(t, isVisible, "Identity %s should not match (case sensitive)", tc.requestIdentityId)
}
})
}
}