Files
CoreRasurae 5559044fda
/ test (push) Successful in 11m2s
feat: Expose Pre-Signed URL support methods via StorageClient API
ISSUES CLOSED: #2
2025-09-23 11:23:15 +01:00

616 lines
27 KiB
Python

"""
S3 Provider Tests
===============
This module contains tests for the S3StorageProvider class.
"""
import os
import io
import pytest
import boto3
import tempfile
# Mock S3 using unittest.mock since moto import is failing
from unittest.mock import patch, MagicMock, wraps, mock_open
from botocore.exceptions import ClientError
def mock_s3(func):
"""Mock S3 decorator using unittest.mock"""
@wraps(func)
def wrapper(self, s3_provider, mock_s3_bucket):
# Setup mock S3 client with patch
with patch('boto3.client') as mock_client, patch('boto3.resource') as mock_resource:
# Create a fresh mock client for each test
mock_s3_client = MagicMock()
mock_client.return_value = mock_s3_client
s3_provider.client = mock_s3_client
s3_provider.resource = mock_resource.return_value
# Setup mock responses with a more flexible read method
mock_body = MagicMock()
mock_body.read.return_value = b'test content'
# Set default return values
mock_s3_client.get_object.return_value = {
'Body': mock_body
}
mock_s3_client.head_object.return_value = {
'ContentLength': 12,
'LastModified': '2023-01-01',
'ETag': '"test-etag"',
'ContentType': 'text/plain',
'Metadata': {}
}
# Configure head_object to raise NoSuchKey for nonexistent files
def head_object_side_effect(**kwargs):
if kwargs.get('Key') == 'nonexistent.txt':
error_response = {'Error': {'Code': 'NoSuchKey', 'Message': 'Not Found'}}
raise ClientError(error_response, 'HeadObject')
return mock_s3_client.head_object.return_value
mock_s3_client.head_object.side_effect = head_object_side_effect
mock_s3_client.list_objects_v2.return_value = {
'Contents': [
{'Key': 'test-file.txt', 'Size': 12, 'LastModified': '2023-01-01', 'ETag': '"test-etag"'},
{'Key': 'test-folder/nested-file.txt', 'Size': 14, 'LastModified': '2023-01-01', 'ETag': '"test-etag"'}
],
'CommonPrefixes': [{'Prefix': 'test-folder/'}]
}
# Setup paginator mock for directory operations
paginator_mock = MagicMock()
mock_s3_client.get_paginator.return_value = paginator_mock
page_mock = {'KeyCount': 1, 'Contents': [{'Key': 'test-folder/'}]}
paginator_mock.paginate.return_value = [page_mock]
# Create a mock for exceptions
class MockExceptions:
class NoSuchKey(ClientError):
def __init__(self):
error_response = {'Error': {'Code': 'NoSuchKey', 'Message': 'Not Found'}}
super().__init__(error_response, 'Operation')
mock_s3_client.exceptions.NoSuchKey = ClientError
# Run the test
return func(self, s3_provider, mock_s3_bucket)
return wrapper
from clevercloud_storage_framework.providers import S3StorageProvider
from clevercloud_storage_framework.exceptions import (
StorageError, InvalidPathError, ResourceNotFoundError, StoragePermissionError
)
@pytest.fixture
def s3_provider():
"""Create an S3StorageProvider instance for testing."""
return S3StorageProvider(region_name='us-east-1')
@pytest.fixture
def mock_s3_bucket():
"""Create a mock S3 bucket for testing."""
return 'test-bucket'
class TestS3StorageProvider:
"""Tests for the S3StorageProvider class."""
def test_parse_path(self, s3_provider):
"""Test parsing S3 paths."""
# Valid paths
assert s3_provider.parse_path('s3://bucket/key') == ('bucket', 'key')
assert s3_provider.parse_path('s3://bucket/') == ('bucket', '')
assert s3_provider.parse_path('s3://bucket') == ('bucket', '')
# Invalid paths
with pytest.raises(InvalidPathError):
s3_provider.parse_path('')
with pytest.raises(InvalidPathError):
s3_provider.parse_path('not-s3://bucket/key')
def test_supports_path(self, s3_provider):
"""Test checking if a path is supported."""
assert s3_provider.supports_path('s3://bucket/key') is True
assert s3_provider.supports_path('efs://filesystem/path') is False
assert s3_provider.supports_path('/local/path') is False
assert s3_provider.supports_path('') is False
def test_get_provider_name(self, s3_provider):
"""Test getting the provider name."""
assert s3_provider.get_provider_name() == 's3'
def test_list_files(self, s3_provider, mock_s3_bucket):
"""Test listing files in a bucket."""
# Use direct patching instead of the decorator
with patch.object(boto3, 'client') as mock_client:
# Setup the mock client
mock_s3_client = MagicMock()
mock_client.return_value = mock_s3_client
s3_provider.client = mock_s3_client
# Configure list_objects_v2 to return our test data
mock_s3_client.list_objects_v2.return_value = {
'Contents': [
{'Key': 'test-file.txt', 'Size': 12, 'LastModified': '2023-01-01', 'ETag': '"test-etag"'},
{'Key': 'test-folder/nested-file.txt', 'Size': 14, 'LastModified': '2023-01-01', 'ETag': '"test-etag"'}
],
'CommonPrefixes': [{'Prefix': 'test-folder/'}]
}
# Create a paginator mock
paginator_mock = MagicMock()
mock_s3_client.get_paginator.return_value = paginator_mock
paginator_mock.paginate.return_value = [mock_s3_client.list_objects_v2.return_value]
# List files in the bucket root
files = list(s3_provider.list_files(f's3://{mock_s3_bucket}'))
# We expect exactly 3 files in the root listing
assert len(files) == 3 # test-file.txt, test-folder/ and test-folder/nested-file.txt
# Check file metadata
file = next(f for f in files if f['name'] == 'test-file.txt')
assert file['path'] == f's3://{mock_s3_bucket}/test-file.txt'
assert file['size'] == 12 # Length of 'test content'
assert file['type'] == 'file'
# Configure list_objects_v2 for recursive listing
mock_s3_client.list_objects_v2.return_value = {
'Contents': [
{'Key': 'test-file.txt', 'Size': 12, 'LastModified': '2023-01-01', 'ETag': '"test-etag"'},
{'Key': 'test-folder/nested-file.txt', 'Size': 14, 'LastModified': '2023-01-01', 'ETag': '"test-etag"'}
]
}
# List files recursively
files = list(s3_provider.list_files(f's3://{mock_s3_bucket}', recursive=True))
assert len(files) == 2 # test-file.txt and test-folder/nested-file.txt
# Configure list_objects_v2 for folder listing
mock_s3_client.list_objects_v2.return_value = {
'Contents': [
{'Key': 'nested-file.txt', 'Size': 14, 'LastModified': '2023-01-01', 'ETag': '"test-etag"'}
]
}
# Update paginator for folder listing
paginator_mock.paginate.return_value = [mock_s3_client.list_objects_v2.return_value]
# List files in a folder
files = list(s3_provider.list_files(f's3://{mock_s3_bucket}/test-folder'))
assert len(files) == 1 # Only one file in the mock data
assert files[0]['name'] == 'nested-file.txt'
@mock_s3
def test_file_exists(self, s3_provider, mock_s3_bucket):
"""Test checking if a file exists."""
assert s3_provider.file_exists(f's3://{mock_s3_bucket}/test-file.txt') is True
assert s3_provider.file_exists(f's3://{mock_s3_bucket}/nonexistent.txt') is False
@mock_s3
def test_get_file_size(self, s3_provider, mock_s3_bucket):
"""Test getting a file's size."""
assert s3_provider.get_file_size(f's3://{mock_s3_bucket}/test-file.txt') == 12
with pytest.raises(ResourceNotFoundError):
s3_provider.get_file_size(f's3://{mock_s3_bucket}/nonexistent.txt')
@mock_s3
def test_get_file_metadata(self, s3_provider, mock_s3_bucket):
"""Test getting a file's metadata."""
metadata = s3_provider.get_file_metadata(f's3://{mock_s3_bucket}/test-file.txt')
assert metadata['name'] == 'test-file.txt'
assert metadata['path'] == f's3://{mock_s3_bucket}/test-file.txt'
assert metadata['size'] == 12
assert metadata['type'] == 'file'
with pytest.raises(ResourceNotFoundError):
s3_provider.get_file_metadata(f's3://{mock_s3_bucket}/nonexistent.txt')
def test_read_file(self, s3_provider, mock_s3_bucket):
"""Test reading a file."""
# Create a proper file-like mock object for the body
mock_body = io.BytesIO(b'test content')
# Patch boto3 client creation
with patch.object(boto3, 'client') as mock_client:
# Setup the mock client
mock_s3_client = MagicMock()
mock_client.return_value = mock_s3_client
# Set the client on the provider
s3_provider.client = mock_s3_client
# Configure get_object to return our mock body
mock_s3_client.get_object.return_value = {'Body': mock_body}
# Configure get_object to raise NoSuchKey for nonexistent files
def get_object_side_effect(**kwargs):
if kwargs.get('Key') == 'nonexistent.txt':
error_response = {'Error': {'Code': 'NoSuchKey', 'Message': 'Not Found'}}
raise ClientError(error_response, 'GetObject')
return {'Body': mock_body}
mock_s3_client.get_object.side_effect = get_object_side_effect
# Test reading an existing file
content = b''.join(s3_provider.read_file(f's3://{mock_s3_bucket}/test-file.txt'))
assert content == b'test content'
# Test reading a non-existent file
with pytest.raises(ResourceNotFoundError):
list(s3_provider.read_file(f's3://{mock_s3_bucket}/nonexistent.txt'))
@mock_s3
def test_write_file(self, s3_provider, mock_s3_bucket):
"""Test writing a file."""
# Write a new file with bytes
s3_provider.write_file(f's3://{mock_s3_bucket}/new-file.txt', b'new content')
# In a mock environment, we just verify the call was made
s3_provider.client.put_object.assert_called_with(
Bucket=mock_s3_bucket,
Key='new-file.txt',
Body=b'new content'
)
# Write a file with a file-like object
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(b'file object content')
f.flush()
f.close()
# Use a mock to verify the call
with patch.object(s3_provider.client, 'upload_fileobj') as mock_upload:
with open(f.name, 'rb') as f2:
s3_provider.write_file(f's3://{mock_s3_bucket}/file-object.txt', f2)
# Verify upload_fileobj was called
mock_upload.assert_called_once()
os.unlink(f.name)
@mock_s3
def test_delete_file(self, s3_provider, mock_s3_bucket):
"""Test deleting a file."""
# Delete an existing file
s3_provider.delete_file(f's3://{mock_s3_bucket}/test-file.txt')
# In a mock environment, we just verify the call was made
s3_provider.client.delete_object.assert_called_with(
Bucket=mock_s3_bucket,
Key='test-file.txt'
)
# Setup for testing deletion of non-existent file
def head_object_side_effect(**kwargs):
if kwargs.get('Key') == 'nonexistent.txt':
error_response = {'Error': {'Code': 'NoSuchKey', 'Message': 'Not Found'}}
raise ClientError(error_response, 'HeadObject')
return s3_provider.client.head_object.return_value
s3_provider.client.head_object.side_effect = head_object_side_effect
# Deleting a non-existent file should raise an error
with pytest.raises(ResourceNotFoundError):
s3_provider.delete_file(f's3://{mock_s3_bucket}/nonexistent.txt')
def test_copy_file(self, s3_provider, mock_s3_bucket):
"""Test copying a file."""
# Patch boto3 client creation
with patch.object(boto3, 'client') as mock_client:
# Setup the mock client
mock_s3_client = MagicMock()
mock_client.return_value = mock_s3_client
s3_provider.client = mock_s3_client
# Create a proper file-like mock object for the body
mock_body = io.BytesIO(b'test content')
# Configure get_object to return our mock body
mock_s3_client.get_object.return_value = {'Body': mock_body}
# Mock file_exists and get_file_size to return appropriate values
with patch.object(s3_provider, 'file_exists', return_value=True), \
patch.object(s3_provider, 'get_file_size', return_value=12): # Return actual size
# S3 to S3 copy
s3_provider.copy_file(
f's3://{mock_s3_bucket}/test-file.txt',
f's3://{mock_s3_bucket}/copy.txt'
)
# Verify the copy_object call
mock_s3_client.copy_object.assert_called_with(
Bucket=mock_s3_bucket,
Key='copy.txt',
CopySource={'Bucket': mock_s3_bucket, 'Key': 'test-file.txt'}
)
# Local to S3 copy - use a temporary file
with tempfile.NamedTemporaryFile() as temp_file:
temp_file.write(b'local content')
temp_file.flush()
# Mock the write_file method to avoid actual file operations
with patch.object(s3_provider, 'write_file') as mock_write:
s3_provider.copy_file(
temp_file.name,
f's3://{mock_s3_bucket}/local-copy.txt'
)
# Verify write_file was called with the correct arguments
mock_write.assert_called_once()
# S3 to local copy - use a mock to avoid actual file operations
local_path = tempfile.mktemp()
# Mock the file operations
with patch('builtins.open', mock_open()) as mock_file:
s3_provider.copy_file(
f's3://{mock_s3_bucket}/test-file.txt',
local_path
)
# Verify that the file was opened for writing
mock_file.assert_called_with(local_path, 'wb')
# Verify that write was called with the test content
mock_file().write.assert_called_with(b'test content')
@mock_s3
def test_move_file(self, s3_provider, mock_s3_bucket):
"""Test moving a file."""
# S3 to S3 move
s3_provider.move_file(
f's3://{mock_s3_bucket}/test-file.txt',
f's3://{mock_s3_bucket}/moved.txt'
)
# In a mock environment, we just verify the calls were made
s3_provider.client.copy_object.assert_called_with(
Bucket=mock_s3_bucket,
Key='moved.txt',
CopySource={'Bucket': mock_s3_bucket, 'Key': 'test-file.txt'}
)
s3_provider.client.delete_object.assert_called_with(
Bucket=mock_s3_bucket,
Key='test-file.txt'
)
@mock_s3
def test_create_directory(self, s3_provider, mock_s3_bucket):
"""Test creating a directory."""
# Create a new directory
s3_provider.create_directory(f's3://{mock_s3_bucket}/new-folder/')
# In a mock environment, we just verify the call was made
s3_provider.client.put_object.assert_called_with(
Bucket=mock_s3_bucket,
Key='new-folder/',
Body=b''
)
@mock_s3
def test_delete_directory(self, s3_provider, mock_s3_bucket):
"""Test deleting a directory."""
# Setup paginator for directory existence check
paginator_mock = MagicMock()
s3_provider.client.get_paginator.return_value = paginator_mock
# Configure paginator to return objects for test-folder/
page_mock = {'KeyCount': 1, 'Contents': [{'Key': 'test-folder/'}]}
paginator_mock.paginate.return_value = [page_mock]
# Delete a directory
s3_provider.delete_directory(f's3://{mock_s3_bucket}/test-folder/')
# In a mock environment, we just verify the call was made
s3_provider.client.delete_object.assert_called_with(
Bucket=mock_s3_bucket,
Key='test-folder/'
)
# Create a new directory and test recursive delete
s3_provider.client.list_objects_v2.return_value = {
'Contents': [
{'Key': 'recursive-folder/'},
{'Key': 'recursive-folder/file1.txt'},
{'Key': 'recursive-folder/file2.txt'}
]
}
# Configure paginator for recursive-folder/
page_mock = {'KeyCount': 3, 'Contents': s3_provider.client.list_objects_v2.return_value['Contents']}
paginator_mock.paginate.return_value = [page_mock]
s3_provider.delete_directory(f's3://{mock_s3_bucket}/recursive-folder/', recursive=True)
# In a mock environment, we just verify the call was made
s3_provider.client.delete_objects.assert_called()
@mock_s3
def test_delete_nonexistent_directory(self, s3_provider, mock_s3_bucket):
"""Test deleting a non-existent directory."""
# Setup paginator for directory existence check
paginator_mock = MagicMock()
s3_provider.client.get_paginator.return_value = paginator_mock
# Configure paginator to return empty results for nonexistent directory
empty_page = {'KeyCount': 0, 'Contents': []}
paginator_mock.paginate.return_value = [empty_page]
with pytest.raises(ResourceNotFoundError):
s3_provider.delete_directory(f's3://{mock_s3_bucket}/nonexistent/')
def test_write_file_with_iterator(self, s3_provider, mock_s3_bucket):
"""Test writing a file with an iterator."""
# Create an iterator of chunks
chunks = [b'chunk1', b'chunk2', b'chunk3']
# Create a concatenated version for verification
concatenated = b''.join(chunks)
# Patch boto3 client creation to ensure we control the mock
with patch.object(boto3, 'client') as mock_client:
# Setup the mock client
mock_s3_client = MagicMock()
mock_client.return_value = mock_s3_client
s3_provider.client = mock_s3_client
# Mock the necessary methods for multipart upload
mock_s3_client.create_multipart_upload.return_value = {'UploadId': 'test-upload-id'}
mock_s3_client.upload_part.return_value = {'ETag': '"test-etag"'}
mock_s3_client.complete_multipart_upload.return_value = {'ETag': '"test-etag"'}
# Write the file
s3_provider.write_file(f's3://{mock_s3_bucket}/iterator-test.txt', iter(chunks))
# In a mock environment, we verify the multipart upload methods were called
mock_s3_client.create_multipart_upload.assert_called_once()
mock_s3_client.upload_part.assert_called()
mock_s3_client.complete_multipart_upload.assert_called_once()
@mock_s3
def test_write_file_with_invalid_content(self, s3_provider, mock_s3_bucket):
"""Test writing a file with invalid content."""
with pytest.raises(ValueError):
s3_provider.write_file(f's3://{mock_s3_bucket}/invalid.txt', 123) # Not a valid content type
@mock_s3
def test_read_file_with_range(self, s3_provider, mock_s3_bucket):
"""Test reading a file with a range."""
# Setup custom responses for range requests
test_content = b'test content'
# Create a function to generate mock bodies with specific content
def create_mock_body(content):
mock_body = io.BytesIO(content)
return mock_body
# Configure get_object to handle different range requests
def get_object_side_effect(**kwargs):
if 'Key' in kwargs and kwargs['Key'] == 'nonexistent.txt':
error_response = {'Error': {'Code': 'NoSuchKey', 'Message': 'Not Found'}}
raise ClientError(error_response, 'GetObject')
if 'Range' in kwargs:
if kwargs['Range'] == 'bytes=5-':
return {'Body': create_mock_body(test_content[5:])}
elif kwargs['Range'] == 'bytes=0-3':
return {'Body': create_mock_body(test_content[:4])}
elif kwargs['Range'] == 'bytes=5-8':
return {'Body': create_mock_body(test_content[5:9])}
# Default response
return {'Body': create_mock_body(test_content)}
# Set the side effect on the get_object method
s3_provider.client.get_object.side_effect = get_object_side_effect
# Read with offset
content = b''.join(s3_provider.read_file(f's3://{mock_s3_bucket}/test-file.txt', offset=5))
assert content == b'content'
# Read with length
content = b''.join(s3_provider.read_file(f's3://{mock_s3_bucket}/test-file.txt', length=4))
assert content == b'test'
# Read with offset and length
content = b''.join(s3_provider.read_file(f's3://{mock_s3_bucket}/test-file.txt', offset=5, length=4))
assert content == b'cont'
def test_supports_presigned_urls(self, s3_provider):
"""Test that S3 provider supports pre-signed URLs."""
assert s3_provider.supports_presigned_urls() is True
@mock_s3
def test_generate_presigned_url_success(self, s3_provider, mock_s3_bucket):
"""Test successful generation of pre-signed URL."""
# Mock the client's generate_presigned_url method
mock_url = 'https://test-bucket.s3.amazonaws.com/test-key?AWSAccessKeyId=test&Signature=test&Expires=1234567890'
s3_provider.client.generate_presigned_url.return_value = mock_url
# Generate pre-signed URL
url = s3_provider.generate_presigned_url(f's3://{mock_s3_bucket}/test-key', expiration=1800)
# Verify the URL was generated correctly
assert url == mock_url
# Verify the client method was called with correct parameters
s3_provider.client.generate_presigned_url.assert_called_once_with(
'get_object',
Params={'Bucket': mock_s3_bucket, 'Key': 'test-key'},
ExpiresIn=1800
)
@mock_s3
def test_generate_presigned_url_default_expiration(self, s3_provider, mock_s3_bucket):
"""Test generation of pre-signed URL with default expiration."""
# Mock the client's generate_presigned_url method
mock_url = 'https://test-bucket.s3.amazonaws.com/test-key?AWSAccessKeyId=test&Signature=test&Expires=1234567890'
s3_provider.client.generate_presigned_url.return_value = mock_url
# Generate pre-signed URL without specifying expiration
url = s3_provider.generate_presigned_url(f's3://{mock_s3_bucket}/test-key')
# Verify the URL was generated correctly
assert url == mock_url
# Verify the client method was called with default expiration (3600 seconds)
s3_provider.client.generate_presigned_url.assert_called_once_with(
'get_object',
Params={'Bucket': mock_s3_bucket, 'Key': 'test-key'},
ExpiresIn=3600
)
@mock_s3
def test_generate_presigned_url_invalid_path(self, s3_provider, mock_s3_bucket):
"""Test generation of pre-signed URL with invalid path."""
from clevercloud_storage_framework.exceptions import InvalidPathError
# Test with invalid path format
with pytest.raises(InvalidPathError):
s3_provider.generate_presigned_url('invalid-path')
@mock_s3
def test_generate_presigned_url_file_not_found(self, s3_provider, mock_s3_bucket):
"""Test generation of pre-signed URL for non-existent file."""
# Note: The current S3 provider implementation doesn't check if the file exists
# before generating a pre-signed URL, so this test verifies that behavior
# Mock the client's generate_presigned_url method to return a URL
mock_url = 'https://test-bucket.s3.amazonaws.com/nonexistent-key?AWSAccessKeyId=test&Signature=test&Expires=1234567890'
s3_provider.client.generate_presigned_url.return_value = mock_url
# Generate pre-signed URL for non-existent file (should succeed)
url = s3_provider.generate_presigned_url(f's3://{mock_s3_bucket}/nonexistent-key')
# Verify the URL was generated correctly
assert url == mock_url
# Verify the client method was called with correct parameters
s3_provider.client.generate_presigned_url.assert_called_once_with(
'get_object',
Params={'Bucket': mock_s3_bucket, 'Key': 'nonexistent-key'},
ExpiresIn=3600
)
@mock_s3
def test_generate_presigned_url_client_error(self, s3_provider, mock_s3_bucket):
"""Test generation of pre-signed URL with client error."""
from clevercloud_storage_framework.exceptions import StorageError
# Mock generate_presigned_url to raise ClientError
error_response = {'Error': {'Code': 'AccessDenied', 'Message': 'Access Denied'}}
s3_provider.client.generate_presigned_url.side_effect = ClientError(error_response, 'GeneratePresignedUrl')
# Test with client error
with pytest.raises(StorageError) as exc_info:
s3_provider.generate_presigned_url(f's3://{mock_s3_bucket}/test-key')
assert "Access Denied" in str(exc_info.value)
assert exc_info.value.provider == "s3"