package s3api import ( "context" "net/http" "net/http/httptest" "net/url" "testing" "time" "github.com/seaweedfs/seaweedfs/weed/iam/integration" "github.com/seaweedfs/seaweedfs/weed/iam/policy" "github.com/seaweedfs/seaweedfs/weed/iam/sts" "github.com/seaweedfs/seaweedfs/weed/iam/utils" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestS3IAMMiddleware tests the basic S3 IAM middleware functionality func TestS3IAMMiddleware(t *testing.T) { // Create IAM manager iamManager := integration.NewIAMManager() // Initialize with test configuration config := &integration.IAMConfig{ STS: &sts.STSConfig{ TokenDuration: sts.FlexibleDuration{time.Hour}, MaxSessionLength: sts.FlexibleDuration{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 := iamManager.Initialize(config, func() string { return "localhost:8888" // Mock filer address for testing }) require.NoError(t, err) // Create S3 IAM integration s3IAMIntegration := NewS3IAMIntegration(iamManager, "localhost:8888") // Test that integration is created successfully assert.NotNil(t, s3IAMIntegration) assert.True(t, s3IAMIntegration.enabled) } // TestS3IAMMiddlewareJWTAuth tests JWT authentication func TestS3IAMMiddlewareJWTAuth(t *testing.T) { // Skip for now since it requires full setup t.Skip("JWT authentication test requires full IAM setup") // Create IAM integration s3iam := NewS3IAMIntegration(nil, "localhost:8888") // Disabled integration // Create test request with JWT token req := httptest.NewRequest("GET", "/test-bucket/test-object", http.NoBody) req.Header.Set("Authorization", "Bearer test-token") // Test authentication (should return not implemented when disabled) ctx := context.Background() identity, errCode := s3iam.AuthenticateJWT(ctx, req) assert.Nil(t, identity) assert.NotEqual(t, errCode, 0) // Should return an error } // TestBuildS3ResourceArn tests resource ARN building func TestBuildS3ResourceArn(t *testing.T) { tests := []struct { name string bucket string object string expected string }{ { name: "empty bucket and object", bucket: "", object: "", expected: "arn:seaweed:s3:::*", }, { name: "bucket only", bucket: "test-bucket", object: "", expected: "arn:seaweed:s3:::test-bucket", }, { name: "bucket and object", bucket: "test-bucket", object: "test-object.txt", expected: "arn:seaweed:s3:::test-bucket/test-object.txt", }, { name: "bucket and object with leading slash", bucket: "test-bucket", object: "/test-object.txt", expected: "arn:seaweed:s3:::test-bucket/test-object.txt", }, { name: "bucket and nested object", bucket: "test-bucket", object: "folder/subfolder/test-object.txt", expected: "arn:seaweed:s3:::test-bucket/folder/subfolder/test-object.txt", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := buildS3ResourceArn(tt.bucket, tt.object) assert.Equal(t, tt.expected, result) }) } } // TestDetermineGranularS3Action tests granular S3 action determination from HTTP requests func TestDetermineGranularS3Action(t *testing.T) { tests := []struct { name string method string bucket string objectKey string queryParams map[string]string fallbackAction Action expected string description string }{ // Object-level operations { name: "get_object", method: "GET", bucket: "test-bucket", objectKey: "test-object.txt", queryParams: map[string]string{}, fallbackAction: s3_constants.ACTION_READ, expected: "s3:GetObject", description: "Basic object retrieval", }, { name: "get_object_acl", method: "GET", bucket: "test-bucket", objectKey: "test-object.txt", queryParams: map[string]string{"acl": ""}, fallbackAction: s3_constants.ACTION_READ_ACP, expected: "s3:GetObjectAcl", description: "Object ACL retrieval", }, { name: "get_object_tagging", method: "GET", bucket: "test-bucket", objectKey: "test-object.txt", queryParams: map[string]string{"tagging": ""}, fallbackAction: s3_constants.ACTION_TAGGING, expected: "s3:GetObjectTagging", description: "Object tagging retrieval", }, { name: "put_object", method: "PUT", bucket: "test-bucket", objectKey: "test-object.txt", queryParams: map[string]string{}, fallbackAction: s3_constants.ACTION_WRITE, expected: "s3:PutObject", description: "Basic object upload", }, { name: "put_object_acl", method: "PUT", bucket: "test-bucket", objectKey: "test-object.txt", queryParams: map[string]string{"acl": ""}, fallbackAction: s3_constants.ACTION_WRITE_ACP, expected: "s3:PutObjectAcl", description: "Object ACL modification", }, { name: "delete_object", method: "DELETE", bucket: "test-bucket", objectKey: "test-object.txt", queryParams: map[string]string{}, fallbackAction: s3_constants.ACTION_WRITE, // DELETE object uses WRITE fallback expected: "s3:DeleteObject", description: "Object deletion - correctly mapped to DeleteObject (not PutObject)", }, { name: "delete_object_tagging", method: "DELETE", bucket: "test-bucket", objectKey: "test-object.txt", queryParams: map[string]string{"tagging": ""}, fallbackAction: s3_constants.ACTION_TAGGING, expected: "s3:DeleteObjectTagging", description: "Object tag deletion", }, // Multipart upload operations { name: "create_multipart_upload", method: "POST", bucket: "test-bucket", objectKey: "large-file.txt", queryParams: map[string]string{"uploads": ""}, fallbackAction: s3_constants.ACTION_WRITE, expected: "s3:CreateMultipartUpload", description: "Multipart upload initiation", }, { name: "upload_part", method: "PUT", bucket: "test-bucket", objectKey: "large-file.txt", queryParams: map[string]string{"uploadId": "12345", "partNumber": "1"}, fallbackAction: s3_constants.ACTION_WRITE, expected: "s3:UploadPart", description: "Multipart part upload", }, { name: "complete_multipart_upload", method: "POST", bucket: "test-bucket", objectKey: "large-file.txt", queryParams: map[string]string{"uploadId": "12345"}, fallbackAction: s3_constants.ACTION_WRITE, expected: "s3:CompleteMultipartUpload", description: "Multipart upload completion", }, { name: "abort_multipart_upload", method: "DELETE", bucket: "test-bucket", objectKey: "large-file.txt", queryParams: map[string]string{"uploadId": "12345"}, fallbackAction: s3_constants.ACTION_WRITE, expected: "s3:AbortMultipartUpload", description: "Multipart upload abort", }, // Bucket-level operations { name: "list_bucket", method: "GET", bucket: "test-bucket", objectKey: "", queryParams: map[string]string{}, fallbackAction: s3_constants.ACTION_LIST, expected: "s3:ListBucket", description: "Bucket listing", }, { name: "get_bucket_acl", method: "GET", bucket: "test-bucket", objectKey: "", queryParams: map[string]string{"acl": ""}, fallbackAction: s3_constants.ACTION_READ_ACP, expected: "s3:GetBucketAcl", description: "Bucket ACL retrieval", }, { name: "put_bucket_policy", method: "PUT", bucket: "test-bucket", objectKey: "", queryParams: map[string]string{"policy": ""}, fallbackAction: s3_constants.ACTION_WRITE, expected: "s3:PutBucketPolicy", description: "Bucket policy modification", }, { name: "delete_bucket", method: "DELETE", bucket: "test-bucket", objectKey: "", queryParams: map[string]string{}, fallbackAction: s3_constants.ACTION_DELETE_BUCKET, expected: "s3:DeleteBucket", description: "Bucket deletion", }, { name: "list_multipart_uploads", method: "GET", bucket: "test-bucket", objectKey: "", queryParams: map[string]string{"uploads": ""}, fallbackAction: s3_constants.ACTION_LIST, expected: "s3:ListMultipartUploads", description: "List multipart uploads in bucket", }, // Fallback scenarios { name: "legacy_read_fallback", method: "GET", bucket: "", objectKey: "", queryParams: map[string]string{}, fallbackAction: s3_constants.ACTION_READ, expected: "s3:GetObject", description: "Legacy read action fallback", }, { name: "already_granular_action", method: "GET", bucket: "", objectKey: "", queryParams: map[string]string{}, fallbackAction: "s3:GetBucketLocation", // Already granular expected: "s3:GetBucketLocation", description: "Already granular action passed through", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create HTTP request with query parameters req := &http.Request{ Method: tt.method, URL: &url.URL{Path: "/" + tt.bucket + "/" + tt.objectKey}, } // Add query parameters query := req.URL.Query() for key, value := range tt.queryParams { query.Set(key, value) } req.URL.RawQuery = query.Encode() // Test the granular action determination result := determineGranularS3Action(req, tt.fallbackAction, tt.bucket, tt.objectKey) assert.Equal(t, tt.expected, result, "Test %s failed: %s. Expected %s but got %s", tt.name, tt.description, tt.expected, result) }) } } // TestMapLegacyActionToIAM tests the legacy action fallback mapping func TestMapLegacyActionToIAM(t *testing.T) { tests := []struct { name string legacyAction Action expected string }{ { name: "read_action_fallback", legacyAction: s3_constants.ACTION_READ, expected: "s3:GetObject", }, { name: "write_action_fallback", legacyAction: s3_constants.ACTION_WRITE, expected: "s3:PutObject", }, { name: "admin_action_fallback", legacyAction: s3_constants.ACTION_ADMIN, expected: "s3:*", }, { name: "granular_multipart_action", legacyAction: s3_constants.ACTION_CREATE_MULTIPART_UPLOAD, expected: "s3:CreateMultipartUpload", }, { name: "unknown_action_with_s3_prefix", legacyAction: "s3:CustomAction", expected: "s3:CustomAction", }, { name: "unknown_action_without_prefix", legacyAction: "CustomAction", expected: "s3:CustomAction", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := mapLegacyActionToIAM(tt.legacyAction) assert.Equal(t, tt.expected, result) }) } } // TestExtractSourceIP tests source IP extraction from requests func TestExtractSourceIP(t *testing.T) { tests := []struct { name string setupReq func() *http.Request expectedIP string }{ { name: "X-Forwarded-For header", setupReq: func() *http.Request { req := httptest.NewRequest("GET", "/test", http.NoBody) req.Header.Set("X-Forwarded-For", "192.168.1.100, 10.0.0.1") return req }, expectedIP: "192.168.1.100", }, { name: "X-Real-IP header", setupReq: func() *http.Request { req := httptest.NewRequest("GET", "/test", http.NoBody) req.Header.Set("X-Real-IP", "192.168.1.200") return req }, expectedIP: "192.168.1.200", }, { name: "RemoteAddr fallback", setupReq: func() *http.Request { req := httptest.NewRequest("GET", "/test", http.NoBody) req.RemoteAddr = "192.168.1.300:12345" return req }, expectedIP: "192.168.1.300", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := tt.setupReq() result := extractSourceIP(req) assert.Equal(t, tt.expectedIP, result) }) } } // TestExtractRoleNameFromPrincipal tests role name extraction func TestExtractRoleNameFromPrincipal(t *testing.T) { tests := []struct { name string principal string expected string }{ { name: "valid assumed role ARN", principal: "arn:seaweed:sts::assumed-role/S3ReadOnlyRole/session-123", expected: "S3ReadOnlyRole", }, { name: "invalid format", principal: "invalid-principal", expected: "", // Returns empty string to signal invalid format }, { name: "missing session name", principal: "arn:seaweed:sts::assumed-role/TestRole", expected: "TestRole", // Extracts role name even without session name }, { name: "empty principal", principal: "", expected: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := utils.ExtractRoleNameFromPrincipal(tt.principal) assert.Equal(t, tt.expected, result) }) } } // TestIAMIdentityIsAdmin tests the IsAdmin method func TestIAMIdentityIsAdmin(t *testing.T) { identity := &IAMIdentity{ Name: "test-identity", Principal: "arn:seaweed:sts::assumed-role/TestRole/session", SessionToken: "test-token", } // In our implementation, IsAdmin always returns false since admin status // is determined by policies, not identity result := identity.IsAdmin() assert.False(t, result) }