Files
seaweedFS/weed/s3api/s3_multipart_iam_test.go
Chris Lu 1261e93ef2 fix: comprehensive go vet error fixes and add CI enforcement (#7861)
* fix: use keyed fields in struct literals

- Replace unsafe reflect.StringHeader/SliceHeader with safe unsafe.String/Slice (weed/query/sqltypes/unsafe.go)
- Add field names to Type_ScalarType struct literals (weed/mq/schema/schema_builder.go)
- Add Duration field name to FlexibleDuration struct literals across test files
- Add field names to bson.D struct literals (weed/filer/mongodb/mongodb_store_kv.go)

Fixes go vet warnings about unkeyed struct literals.

* fix: remove unreachable code

- Remove unreachable return statements after infinite for loops
- Remove unreachable code after if/else blocks where all paths return
- Simplify recursive logic by removing unnecessary for loop (inode_to_path.go)
- Fix Type_ScalarType literal to use enum value directly (schema_builder.go)
- Call onCompletionFn on stream error (subscribe_session.go)

Files fixed:
- weed/query/sqltypes/unsafe.go
- weed/mq/schema/schema_builder.go
- weed/mq/client/sub_client/connect_to_sub_coordinator.go
- weed/filer/redis3/ItemList.go
- weed/mq/client/agent_client/subscribe_session.go
- weed/mq/broker/broker_grpc_pub_balancer.go
- weed/mount/inode_to_path.go
- weed/util/skiplist/name_list.go

* fix: avoid copying lock values in protobuf messages

- Use proto.Merge() instead of direct assignment to avoid copying sync.Mutex in S3ApiConfiguration (iamapi_server.go)
- Add explicit comments noting that channel-received values are already copies before taking addresses (volume_grpc_client_to_master.go)

The protobuf messages contain sync.Mutex fields from the message state, which should not be copied.
Using proto.Merge() properly merges messages without copying the embedded mutex.

* fix: correct byte array size for uint32 bit shift operations

The generateAccountId() function only needs 4 bytes to create a uint32 value.
Changed from allocating 8 bytes to 4 bytes to match the actual usage.

This fixes go vet warning about shifting 8-bit values (bytes) by more than 8 bits.

* fix: ensure context cancellation on all error paths

In broker_client_subscribe.go, ensure subscriberCancel() is called on all error return paths:
- When stream creation fails
- When partition assignment fails
- When sending initialization message fails

This prevents context leaks when an error occurs during subscriber creation.

* fix: ensure subscriberCancel called for CreateFreshSubscriber stream.Send error

Ensure subscriberCancel() is called when stream.Send fails in CreateFreshSubscriber.

* ci: add go vet step to prevent future lint regressions

- Add go vet step to GitHub Actions workflow
- Filter known protobuf lock warnings (MessageState sync.Mutex)
  These are expected in generated protobuf code and are safe
- Prevents accumulation of go vet errors in future PRs
- Step runs before build to catch issues early

* fix: resolve remaining syntax and logic errors in vet fixes

- Fixed syntax errors in filer_sync.go caused by missing closing braces
- Added missing closing brace for if block and function
- Synchronized fixes to match previous commits on branch

* fix: add missing return statements to daemon functions

- Add 'return false' after infinite loops in filer_backup.go and filer_meta_backup.go
- Satisfies declared bool return type signatures
- Maintains consistency with other daemon functions (runMaster, runFilerSynchronize, runWorker)
- While unreachable, explicitly declares the return satisfies function signature contract

* fix: add nil check for onCompletionFn in SubscribeMessageRecord

- Check if onCompletionFn is not nil before calling it
- Prevents potential panic if nil function is passed
- Matches pattern used in other callback functions

* docs: clarify unreachable return statements in daemon functions

- Add comments documenting that return statements satisfy function signature
- Explains that these returns follow infinite loops and are unreachable
- Improves code clarity for future maintainers
2025-12-23 14:48:50 -08:00

615 lines
19 KiB
Go

package s3api
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
"github.com/seaweedfs/seaweedfs/weed/iam/ldap"
"github.com/seaweedfs/seaweedfs/weed/iam/oidc"
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
"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"
)
// createTestJWTMultipart creates a test JWT token with the specified issuer, subject and signing key
func createTestJWTMultipart(t *testing.T, issuer, subject, signingKey string) string {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iss": issuer,
"sub": subject,
"aud": "test-client-id",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
// Add claims that trust policy validation expects
"idp": "test-oidc", // Identity provider claim for trust policy matching
})
tokenString, err := token.SignedString([]byte(signingKey))
require.NoError(t, err)
return tokenString
}
// TestMultipartIAMValidation tests IAM validation for multipart operations
func TestMultipartIAMValidation(t *testing.T) {
// Set up IAM system
iamManager := setupTestIAMManagerForMultipart(t)
s3iam := NewS3IAMIntegration(iamManager, "localhost:8888")
s3iam.enabled = true
// Create IAM with integration
iam := &IdentityAccessManagement{
isAuthEnabled: true,
}
iam.SetIAMIntegration(s3iam)
// Set up roles
ctx := context.Background()
setupTestRolesForMultipart(ctx, iamManager)
// Create a valid JWT token for testing
validJWTToken := createTestJWTMultipart(t, "https://test-issuer.com", "test-user-123", "test-signing-key")
// Get session token
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityRequest{
RoleArn: "arn:aws:iam::role/S3WriteRole",
WebIdentityToken: validJWTToken,
RoleSessionName: "multipart-test-session",
})
require.NoError(t, err)
sessionToken := response.Credentials.SessionToken
tests := []struct {
name string
operation MultipartOperation
method string
path string
sessionToken string
expectedResult s3err.ErrorCode
}{
{
name: "Initiate multipart upload",
operation: MultipartOpInitiate,
method: "POST",
path: "/test-bucket/test-file.txt?uploads",
sessionToken: sessionToken,
expectedResult: s3err.ErrNone,
},
{
name: "Upload part",
operation: MultipartOpUploadPart,
method: "PUT",
path: "/test-bucket/test-file.txt?partNumber=1&uploadId=test-upload-id",
sessionToken: sessionToken,
expectedResult: s3err.ErrNone,
},
{
name: "Complete multipart upload",
operation: MultipartOpComplete,
method: "POST",
path: "/test-bucket/test-file.txt?uploadId=test-upload-id",
sessionToken: sessionToken,
expectedResult: s3err.ErrNone,
},
{
name: "Abort multipart upload",
operation: MultipartOpAbort,
method: "DELETE",
path: "/test-bucket/test-file.txt?uploadId=test-upload-id",
sessionToken: sessionToken,
expectedResult: s3err.ErrNone,
},
{
name: "List multipart uploads",
operation: MultipartOpList,
method: "GET",
path: "/test-bucket?uploads",
sessionToken: sessionToken,
expectedResult: s3err.ErrNone,
},
{
name: "Upload part without session token",
operation: MultipartOpUploadPart,
method: "PUT",
path: "/test-bucket/test-file.txt?partNumber=1&uploadId=test-upload-id",
sessionToken: "",
expectedResult: s3err.ErrNone, // Falls back to standard auth
},
{
name: "Upload part with invalid session token",
operation: MultipartOpUploadPart,
method: "PUT",
path: "/test-bucket/test-file.txt?partNumber=1&uploadId=test-upload-id",
sessionToken: "invalid-token",
expectedResult: s3err.ErrAccessDenied,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create request for multipart operation
req := createMultipartRequest(t, tt.method, tt.path, tt.sessionToken)
// Create identity for testing
identity := &Identity{
Name: "test-user",
Account: &AccountAdmin,
}
// Test validation
result := iam.ValidateMultipartOperationWithIAM(req, identity, tt.operation)
assert.Equal(t, tt.expectedResult, result, "Multipart IAM validation result should match expected")
})
}
}
// TestMultipartUploadPolicy tests multipart upload security policies
func TestMultipartUploadPolicy(t *testing.T) {
policy := &MultipartUploadPolicy{
MaxPartSize: 10 * 1024 * 1024, // 10MB for testing
MinPartSize: 5 * 1024 * 1024, // 5MB minimum
MaxParts: 100, // 100 parts max for testing
AllowedContentTypes: []string{"application/json", "text/plain"},
RequiredHeaders: []string{"Content-Type"},
}
tests := []struct {
name string
request *MultipartUploadRequest
expectedError string
}{
{
name: "Valid upload part request",
request: &MultipartUploadRequest{
Bucket: "test-bucket",
ObjectKey: "test-file.txt",
PartNumber: 1,
Operation: string(MultipartOpUploadPart),
ContentSize: 8 * 1024 * 1024, // 8MB
Headers: map[string]string{
"Content-Type": "application/json",
},
},
expectedError: "",
},
{
name: "Part size too large",
request: &MultipartUploadRequest{
Bucket: "test-bucket",
ObjectKey: "test-file.txt",
PartNumber: 1,
Operation: string(MultipartOpUploadPart),
ContentSize: 15 * 1024 * 1024, // 15MB exceeds limit
Headers: map[string]string{
"Content-Type": "application/json",
},
},
expectedError: "part size",
},
{
name: "Invalid part number (too high)",
request: &MultipartUploadRequest{
Bucket: "test-bucket",
ObjectKey: "test-file.txt",
PartNumber: 150, // Exceeds max parts
Operation: string(MultipartOpUploadPart),
ContentSize: 8 * 1024 * 1024,
Headers: map[string]string{
"Content-Type": "application/json",
},
},
expectedError: "part number",
},
{
name: "Invalid part number (too low)",
request: &MultipartUploadRequest{
Bucket: "test-bucket",
ObjectKey: "test-file.txt",
PartNumber: 0, // Must be >= 1
Operation: string(MultipartOpUploadPart),
ContentSize: 8 * 1024 * 1024,
Headers: map[string]string{
"Content-Type": "application/json",
},
},
expectedError: "part number",
},
{
name: "Content type not allowed",
request: &MultipartUploadRequest{
Bucket: "test-bucket",
ObjectKey: "test-file.txt",
PartNumber: 1,
Operation: string(MultipartOpUploadPart),
ContentSize: 8 * 1024 * 1024,
Headers: map[string]string{
"Content-Type": "video/mp4", // Not in allowed list
},
},
expectedError: "content type video/mp4 is not allowed",
},
{
name: "Missing required header",
request: &MultipartUploadRequest{
Bucket: "test-bucket",
ObjectKey: "test-file.txt",
PartNumber: 1,
Operation: string(MultipartOpUploadPart),
ContentSize: 8 * 1024 * 1024,
Headers: map[string]string{}, // Missing Content-Type
},
expectedError: "required header Content-Type is missing",
},
{
name: "Non-upload operation (should not validate size)",
request: &MultipartUploadRequest{
Bucket: "test-bucket",
ObjectKey: "test-file.txt",
Operation: string(MultipartOpInitiate),
Headers: map[string]string{
"Content-Type": "application/json",
},
},
expectedError: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := policy.ValidateMultipartRequestWithPolicy(tt.request)
if tt.expectedError == "" {
assert.NoError(t, err, "Policy validation should succeed")
} else {
assert.Error(t, err, "Policy validation should fail")
assert.Contains(t, err.Error(), tt.expectedError, "Error message should contain expected text")
}
})
}
}
// TestMultipartS3ActionMapping tests the mapping of multipart operations to S3 actions
func TestMultipartS3ActionMapping(t *testing.T) {
tests := []struct {
operation MultipartOperation
expectedAction Action
}{
{MultipartOpInitiate, s3_constants.ACTION_CREATE_MULTIPART_UPLOAD},
{MultipartOpUploadPart, s3_constants.ACTION_UPLOAD_PART},
{MultipartOpComplete, s3_constants.ACTION_COMPLETE_MULTIPART},
{MultipartOpAbort, s3_constants.ACTION_ABORT_MULTIPART},
{MultipartOpList, s3_constants.ACTION_LIST_MULTIPART_UPLOADS},
{MultipartOpListParts, s3_constants.ACTION_LIST_PARTS},
{MultipartOperation("unknown"), "s3:InternalErrorUnknownMultipartAction"}, // Fail-closed for security
}
for _, tt := range tests {
t.Run(string(tt.operation), func(t *testing.T) {
action := determineMultipartS3Action(tt.operation)
assert.Equal(t, tt.expectedAction, action, "S3 action mapping should match expected")
})
}
}
// TestSessionTokenExtraction tests session token extraction from various sources
func TestSessionTokenExtraction(t *testing.T) {
tests := []struct {
name string
setupRequest func() *http.Request
expectedToken string
}{
{
name: "Bearer token in Authorization header",
setupRequest: func() *http.Request {
req := httptest.NewRequest("PUT", "/test-bucket/test-file.txt", nil)
req.Header.Set("Authorization", "Bearer test-session-token-123")
return req
},
expectedToken: "test-session-token-123",
},
{
name: "X-Amz-Security-Token header",
setupRequest: func() *http.Request {
req := httptest.NewRequest("PUT", "/test-bucket/test-file.txt", nil)
req.Header.Set("X-Amz-Security-Token", "security-token-456")
return req
},
expectedToken: "security-token-456",
},
{
name: "X-Amz-Security-Token query parameter",
setupRequest: func() *http.Request {
req := httptest.NewRequest("PUT", "/test-bucket/test-file.txt?X-Amz-Security-Token=query-token-789", nil)
return req
},
expectedToken: "query-token-789",
},
{
name: "No token present",
setupRequest: func() *http.Request {
return httptest.NewRequest("PUT", "/test-bucket/test-file.txt", nil)
},
expectedToken: "",
},
{
name: "Authorization header without Bearer",
setupRequest: func() *http.Request {
req := httptest.NewRequest("PUT", "/test-bucket/test-file.txt", nil)
req.Header.Set("Authorization", "AWS access_key:signature")
return req
},
expectedToken: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := tt.setupRequest()
token := extractSessionTokenFromRequest(req)
assert.Equal(t, tt.expectedToken, token, "Extracted token should match expected")
})
}
}
// TestUploadPartValidation tests upload part request validation
func TestUploadPartValidation(t *testing.T) {
s3Server := &S3ApiServer{}
tests := []struct {
name string
setupRequest func() *http.Request
expectedError string
}{
{
name: "Valid upload part request",
setupRequest: func() *http.Request {
req := httptest.NewRequest("PUT", "/test-bucket/test-file.txt?partNumber=1&uploadId=test-123", nil)
req.Header.Set("Content-Type", "application/octet-stream")
req.ContentLength = 6 * 1024 * 1024 // 6MB
return req
},
expectedError: "",
},
{
name: "Missing partNumber parameter",
setupRequest: func() *http.Request {
req := httptest.NewRequest("PUT", "/test-bucket/test-file.txt?uploadId=test-123", nil)
req.Header.Set("Content-Type", "application/octet-stream")
req.ContentLength = 6 * 1024 * 1024
return req
},
expectedError: "missing partNumber parameter",
},
{
name: "Invalid partNumber format",
setupRequest: func() *http.Request {
req := httptest.NewRequest("PUT", "/test-bucket/test-file.txt?partNumber=abc&uploadId=test-123", nil)
req.Header.Set("Content-Type", "application/octet-stream")
req.ContentLength = 6 * 1024 * 1024
return req
},
expectedError: "invalid partNumber",
},
{
name: "Part size too large",
setupRequest: func() *http.Request {
req := httptest.NewRequest("PUT", "/test-bucket/test-file.txt?partNumber=1&uploadId=test-123", nil)
req.Header.Set("Content-Type", "application/octet-stream")
req.ContentLength = 6 * 1024 * 1024 * 1024 // 6GB exceeds 5GB limit
return req
},
expectedError: "part size",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := tt.setupRequest()
err := s3Server.validateUploadPartRequest(req)
if tt.expectedError == "" {
assert.NoError(t, err, "Upload part validation should succeed")
} else {
assert.Error(t, err, "Upload part validation should fail")
assert.Contains(t, err.Error(), tt.expectedError, "Error message should contain expected text")
}
})
}
}
// TestDefaultMultipartUploadPolicy tests the default policy configuration
func TestDefaultMultipartUploadPolicy(t *testing.T) {
policy := DefaultMultipartUploadPolicy()
assert.Equal(t, int64(5*1024*1024*1024), policy.MaxPartSize, "Max part size should be 5GB")
assert.Equal(t, int64(5*1024*1024), policy.MinPartSize, "Min part size should be 5MB")
assert.Equal(t, 10000, policy.MaxParts, "Max parts should be 10,000")
assert.Equal(t, 7*24*time.Hour, policy.MaxUploadDuration, "Max upload duration should be 7 days")
assert.Empty(t, policy.AllowedContentTypes, "Should allow all content types by default")
assert.Empty(t, policy.RequiredHeaders, "Should have no required headers by default")
assert.Empty(t, policy.IPWhitelist, "Should have no IP restrictions by default")
}
// TestMultipartUploadSession tests multipart upload session structure
func TestMultipartUploadSession(t *testing.T) {
session := &MultipartUploadSession{
UploadID: "test-upload-123",
Bucket: "test-bucket",
ObjectKey: "test-file.txt",
Initiator: "arn:aws:iam::user/testuser",
Owner: "arn:aws:iam::user/testuser",
CreatedAt: time.Now(),
Parts: []MultipartUploadPart{
{
PartNumber: 1,
Size: 5 * 1024 * 1024,
ETag: "abc123",
LastModified: time.Now(),
Checksum: "sha256:def456",
},
},
Metadata: map[string]string{
"Content-Type": "application/octet-stream",
"x-amz-meta-custom": "value",
},
Policy: DefaultMultipartUploadPolicy(),
SessionToken: "session-token-789",
}
assert.NotEmpty(t, session.UploadID, "Upload ID should not be empty")
assert.NotEmpty(t, session.Bucket, "Bucket should not be empty")
assert.NotEmpty(t, session.ObjectKey, "Object key should not be empty")
assert.Len(t, session.Parts, 1, "Should have one part")
assert.Equal(t, 1, session.Parts[0].PartNumber, "Part number should be 1")
assert.NotNil(t, session.Policy, "Policy should not be nil")
}
// Helper functions for tests
func setupTestIAMManagerForMultipart(t *testing.T) *integration.IAMManager {
// Create IAM manager
manager := integration.NewIAMManager()
// Initialize with test configuration
config := &integration.IAMConfig{
STS: &sts.STSConfig{
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
MaxSessionLength: sts.FlexibleDuration{Duration: time.Hour * 12},
Issuer: "test-sts",
SigningKey: []byte("test-signing-key-32-characters-long"),
},
Policy: &policy.PolicyEngineConfig{
DefaultEffect: "Deny",
StoreType: "memory",
},
Roles: &integration.RoleStoreConfig{
StoreType: "memory",
},
}
err := manager.Initialize(config, func() string {
return "localhost:8888" // Mock filer address for testing
})
require.NoError(t, err)
// Set up test identity providers
setupTestProvidersForMultipart(t, manager)
return manager
}
func setupTestProvidersForMultipart(t *testing.T, manager *integration.IAMManager) {
// Set up OIDC provider
oidcProvider := oidc.NewMockOIDCProvider("test-oidc")
oidcConfig := &oidc.OIDCConfig{
Issuer: "https://test-issuer.com",
ClientID: "test-client-id",
}
err := oidcProvider.Initialize(oidcConfig)
require.NoError(t, err)
oidcProvider.SetupDefaultTestData()
// Set up LDAP provider
ldapProvider := ldap.NewMockLDAPProvider("test-ldap")
err = ldapProvider.Initialize(nil) // Mock doesn't need real config
require.NoError(t, err)
ldapProvider.SetupDefaultTestData()
// Register providers
err = manager.RegisterIdentityProvider(oidcProvider)
require.NoError(t, err)
err = manager.RegisterIdentityProvider(ldapProvider)
require.NoError(t, err)
}
func setupTestRolesForMultipart(ctx context.Context, manager *integration.IAMManager) {
// Create write policy for multipart operations
writePolicy := &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{
{
Sid: "AllowS3MultipartOperations",
Effect: "Allow",
Action: []string{
"s3:PutObject",
"s3:GetObject",
"s3:ListBucket",
"s3:DeleteObject",
"s3:CreateMultipartUpload",
"s3:UploadPart",
"s3:CompleteMultipartUpload",
"s3:AbortMultipartUpload",
"s3:ListBucketMultipartUploads",
"s3:ListMultipartUploadParts",
},
Resource: []string{
"arn:aws:s3:::*",
"arn:aws:s3:::*/*",
},
},
},
}
manager.CreatePolicy(ctx, "", "S3WritePolicy", writePolicy)
// Create write role
manager.CreateRole(ctx, "", "S3WriteRole", &integration.RoleDefinition{
RoleName: "S3WriteRole",
TrustPolicy: &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{
{
Effect: "Allow",
Principal: map[string]interface{}{
"Federated": "test-oidc",
},
Action: []string{"sts:AssumeRoleWithWebIdentity"},
},
},
},
AttachedPolicies: []string{"S3WritePolicy"},
})
// Create a role for multipart users
manager.CreateRole(ctx, "", "MultipartUser", &integration.RoleDefinition{
RoleName: "MultipartUser",
TrustPolicy: &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{
{
Effect: "Allow",
Principal: map[string]interface{}{
"Federated": "test-oidc",
},
Action: []string{"sts:AssumeRoleWithWebIdentity"},
},
},
},
AttachedPolicies: []string{"S3WritePolicy"},
})
}
func createMultipartRequest(t *testing.T, method, path, sessionToken string) *http.Request {
req := httptest.NewRequest(method, path, nil)
// Add session token if provided
if sessionToken != "" {
req.Header.Set("Authorization", "Bearer "+sessionToken)
// Set the principal ARN header that matches the assumed role from the test setup
// This corresponds to the role "arn:aws:iam::role/S3WriteRole" with session name "multipart-test-session"
req.Header.Set("X-SeaweedFS-Principal", "arn:aws:sts::assumed-role/S3WriteRole/multipart-test-session")
}
// Add common headers
req.Header.Set("Content-Type", "application/octet-stream")
return req
}