513 lines
21 KiB
Python
513 lines
21 KiB
Python
"""
|
|
Copyright 2024-2026 ChatterMate
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
"""
|
|
|
|
import pytest
|
|
import base64
|
|
import os
|
|
import tempfile
|
|
from unittest.mock import Mock, patch, AsyncMock, MagicMock
|
|
from uuid import uuid4
|
|
|
|
from app.services.file_upload_service import (
|
|
FileUploadService,
|
|
ALLOWED_IMAGE_TYPES,
|
|
ALLOWED_DOCUMENT_TYPES,
|
|
ALLOWED_FILE_TYPES,
|
|
MAX_FILE_SIZE,
|
|
MAX_IMAGE_SIZE
|
|
)
|
|
|
|
|
|
class TestFileUploadServiceValidation:
|
|
"""Test file validation methods"""
|
|
|
|
def test_validate_file_type_allowed_image(self):
|
|
"""Test validation of allowed image types"""
|
|
for content_type in ALLOWED_IMAGE_TYPES:
|
|
assert FileUploadService.validate_file_type(content_type) is True
|
|
|
|
def test_validate_file_type_allowed_document(self):
|
|
"""Test validation of allowed document types"""
|
|
for content_type in ALLOWED_DOCUMENT_TYPES:
|
|
assert FileUploadService.validate_file_type(content_type) is True
|
|
|
|
def test_validate_file_type_disallowed(self):
|
|
"""Test validation of disallowed file types"""
|
|
disallowed_types = [
|
|
'video/mp4',
|
|
'audio/mp3',
|
|
'application/x-executable',
|
|
'text/html',
|
|
'application/javascript'
|
|
]
|
|
for content_type in disallowed_types:
|
|
assert FileUploadService.validate_file_type(content_type) is False
|
|
|
|
def test_validate_file_size_image_within_limit(self):
|
|
"""Test image file size validation within limits"""
|
|
# Test image under image size limit
|
|
image_size = MAX_IMAGE_SIZE - 1000 # 1KB under limit
|
|
is_valid, error = FileUploadService.validate_file_size(image_size, 'image/jpeg')
|
|
assert is_valid is True
|
|
assert error is None
|
|
|
|
def test_validate_file_size_image_exceeds_image_limit(self):
|
|
"""Test image file size validation exceeding image limit"""
|
|
# Test image over image size limit
|
|
image_size = MAX_IMAGE_SIZE + 1000 # 1KB over image limit
|
|
is_valid, error = FileUploadService.validate_file_size(image_size, 'image/jpeg')
|
|
assert is_valid is False
|
|
assert "Image file size exceeds maximum" in error
|
|
assert "5.0MB" in error
|
|
|
|
def test_validate_file_size_document_within_limit(self):
|
|
"""Test document file size validation within limits"""
|
|
# Test document under general size limit
|
|
doc_size = MAX_FILE_SIZE - 1000 # 1KB under limit
|
|
is_valid, error = FileUploadService.validate_file_size(doc_size, 'application/pdf')
|
|
assert is_valid is True
|
|
assert error is None
|
|
|
|
def test_validate_file_size_document_exceeds_limit(self):
|
|
"""Test document file size validation exceeding limit"""
|
|
# Test document over general size limit
|
|
doc_size = MAX_FILE_SIZE + 1000 # 1KB over limit
|
|
is_valid, error = FileUploadService.validate_file_size(doc_size, 'application/pdf')
|
|
assert is_valid is False
|
|
assert "File size exceeds maximum" in error
|
|
assert "10.0MB" in error
|
|
|
|
def test_validate_file_size_image_exceeds_general_limit(self):
|
|
"""Test image file size validation exceeding general limit"""
|
|
# Test image over general size limit (but under image limit is irrelevant)
|
|
image_size = MAX_FILE_SIZE + 1000 # 1KB over general limit
|
|
is_valid, error = FileUploadService.validate_file_size(image_size, 'image/png')
|
|
assert is_valid is False
|
|
# The service checks image size limit first, so we get image-specific error
|
|
assert "Image file size exceeds maximum" in error
|
|
assert "5.0MB" in error
|
|
|
|
|
|
class TestFileUploadServiceUpload:
|
|
"""Test file upload functionality"""
|
|
|
|
@pytest.fixture
|
|
def sample_file_data(self):
|
|
"""Create sample file data for testing"""
|
|
content = b"This is a test file content"
|
|
encoded_content = base64.b64encode(content).decode('utf-8')
|
|
return {
|
|
'content': encoded_content,
|
|
'filename': 'test_file.txt',
|
|
'content_type': 'text/plain',
|
|
'size': len(content)
|
|
}
|
|
|
|
@pytest.fixture
|
|
def sample_image_data(self):
|
|
"""Create sample image data for testing"""
|
|
# Simple fake image content
|
|
content = b"fake_image_data_" * 100 # Small image
|
|
encoded_content = base64.b64encode(content).decode('utf-8')
|
|
return {
|
|
'content': encoded_content,
|
|
'filename': 'test_image.jpg',
|
|
'content_type': 'image/jpeg',
|
|
'size': len(content)
|
|
}
|
|
|
|
@pytest.fixture
|
|
def large_file_data(self):
|
|
"""Create large file data that exceeds size limits"""
|
|
content = b"x" * (MAX_FILE_SIZE + 1000) # Exceeds max file size
|
|
encoded_content = base64.b64encode(content).decode('utf-8')
|
|
return {
|
|
'content': encoded_content,
|
|
'filename': 'large_file.pdf',
|
|
'content_type': 'application/pdf',
|
|
'size': len(content)
|
|
}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_file_s3_success(self, sample_file_data):
|
|
"""Test successful file upload to S3"""
|
|
org_id = str(uuid4())
|
|
user_id = str(uuid4())
|
|
|
|
with patch('app.services.file_upload_service.settings') as mock_settings, \
|
|
patch('app.services.file_upload_service.upload_file_to_s3') as mock_s3_upload, \
|
|
patch('app.services.file_upload_service.resolve_public_url') as mock_signed_url, \
|
|
patch('uuid.uuid4') as mock_uuid:
|
|
|
|
# Configure mocks
|
|
mock_settings.S3_FILE_STORAGE = True
|
|
mock_uuid.return_value = Mock()
|
|
mock_uuid.return_value.__str__ = Mock(return_value='test-uuid-123')
|
|
|
|
mock_s3_upload.return_value = 'https://s3.amazonaws.com/bucket/chat_attachments/org123/test-uuid-123.txt'
|
|
mock_signed_url.return_value = 'https://s3.amazonaws.com/bucket/signed-url'
|
|
|
|
result = await FileUploadService.upload_file(
|
|
file_data=sample_file_data,
|
|
org_id=org_id,
|
|
user_id=user_id
|
|
)
|
|
|
|
# Verify S3 upload was called
|
|
mock_s3_upload.assert_called_once()
|
|
call_args = mock_s3_upload.call_args
|
|
assert call_args[1]['folder'] == f'chat_attachments/{org_id}'
|
|
assert call_args[1]['filename'] == 'test-uuid-123.txt'
|
|
assert call_args[1]['content_type'] == 'text/plain'
|
|
|
|
# Verify signed URL was generated
|
|
mock_signed_url.assert_called_once()
|
|
|
|
# Verify result
|
|
assert result['file_url'] == 'https://s3.amazonaws.com/bucket/chat_attachments/org123/test-uuid-123.txt'
|
|
assert result['signed_url'] == 'https://s3.amazonaws.com/bucket/signed-url'
|
|
assert result['filename'] == 'test_file.txt'
|
|
assert result['content_type'] == 'text/plain'
|
|
assert result['size'] == len(base64.b64decode(sample_file_data['content']))
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_file_local_success(self, sample_file_data):
|
|
"""Test successful file upload to local storage"""
|
|
org_id = str(uuid4())
|
|
customer_id = str(uuid4())
|
|
|
|
with patch('app.services.file_upload_service.settings') as mock_settings, \
|
|
patch('uuid.uuid4') as mock_uuid, \
|
|
patch('os.makedirs') as mock_makedirs, \
|
|
patch('builtins.open', create=True) as mock_open:
|
|
|
|
# Configure mocks
|
|
mock_settings.S3_FILE_STORAGE = False
|
|
mock_uuid.return_value = Mock()
|
|
mock_uuid.return_value.__str__ = Mock(return_value='test-uuid-456')
|
|
|
|
# Mock file operations
|
|
mock_file = MagicMock()
|
|
mock_open.return_value.__enter__.return_value = mock_file
|
|
|
|
result = await FileUploadService.upload_file(
|
|
file_data=sample_file_data,
|
|
org_id=org_id,
|
|
customer_id=customer_id
|
|
)
|
|
|
|
# Verify directory creation
|
|
mock_makedirs.assert_called_once_with(f'uploads/chat_attachments/{org_id}', exist_ok=True)
|
|
|
|
# Verify file write
|
|
mock_open.assert_called_once_with(f'uploads/chat_attachments/{org_id}/test-uuid-456.txt', 'wb')
|
|
mock_file.write.assert_called_once()
|
|
|
|
# Verify result
|
|
assert result['file_url'] == f'/uploads/chat_attachments/{org_id}/test-uuid-456.txt'
|
|
# Local attachments are never static-served: clients get a signed link.
|
|
assert result['signed_url'].startswith(
|
|
f'/api/v1/files/local/chat_attachments/{org_id}/test-uuid-456.txt?exp=')
|
|
assert result['filename'] == 'test_file.txt'
|
|
assert result['content_type'] == 'text/plain'
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_file_missing_content(self):
|
|
"""Test upload with missing content"""
|
|
file_data = {
|
|
'filename': 'test.txt',
|
|
'content_type': 'text/plain'
|
|
# Missing 'content'
|
|
}
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
await FileUploadService.upload_file(
|
|
file_data=file_data,
|
|
org_id=str(uuid4())
|
|
)
|
|
|
|
assert "Missing required file data" in str(exc_info.value)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_file_missing_filename(self):
|
|
"""Test upload with missing filename"""
|
|
file_data = {
|
|
'content': base64.b64encode(b"test content").decode('utf-8'),
|
|
'content_type': 'text/plain'
|
|
# Missing 'filename'
|
|
}
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
await FileUploadService.upload_file(
|
|
file_data=file_data,
|
|
org_id=str(uuid4())
|
|
)
|
|
|
|
assert "Missing required file data" in str(exc_info.value)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_file_invalid_type(self, sample_file_data):
|
|
"""Test upload with invalid file type"""
|
|
# Modify to invalid type
|
|
sample_file_data['content_type'] = 'video/mp4'
|
|
sample_file_data['filename'] = 'test.mp4'
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
await FileUploadService.upload_file(
|
|
file_data=sample_file_data,
|
|
org_id=str(uuid4())
|
|
)
|
|
|
|
# Error should indicate the file type is not allowed
|
|
assert "not allowed" in str(exc_info.value) or "File type" in str(exc_info.value)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_file_invalid_base64(self):
|
|
"""Test upload with invalid base64 content"""
|
|
file_data = {
|
|
'content': 'Z', # Invalid base64 - single character that can't be decoded properly
|
|
'filename': 'test.txt',
|
|
'content_type': 'text/plain'
|
|
}
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
await FileUploadService.upload_file(
|
|
file_data=file_data,
|
|
org_id=str(uuid4())
|
|
)
|
|
|
|
assert "Invalid file content encoding" in str(exc_info.value)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_file_size_exceeds_limit(self, large_file_data):
|
|
"""Test upload with file size exceeding limits"""
|
|
with pytest.raises(ValueError) as exc_info:
|
|
await FileUploadService.upload_file(
|
|
file_data=large_file_data,
|
|
org_id=str(uuid4())
|
|
)
|
|
|
|
assert "File size exceeds maximum" in str(exc_info.value)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_file_image_size_exceeds_limit(self):
|
|
"""Test upload with image size exceeding image limits"""
|
|
# Create large image that exceeds image size limit
|
|
content = b"x" * (MAX_IMAGE_SIZE + 1000)
|
|
encoded_content = base64.b64encode(content).decode('utf-8')
|
|
|
|
file_data = {
|
|
'content': encoded_content,
|
|
'filename': 'large_image.jpg',
|
|
'content_type': 'image/jpeg'
|
|
}
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
await FileUploadService.upload_file(
|
|
file_data=file_data,
|
|
org_id=str(uuid4())
|
|
)
|
|
|
|
assert "Image file size exceeds maximum" in str(exc_info.value)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_file_s3_upload_failure(self, sample_file_data):
|
|
"""Test handling of S3 upload failure"""
|
|
with patch('app.services.file_upload_service.settings') as mock_settings, \
|
|
patch('app.services.file_upload_service.upload_file_to_s3') as mock_s3_upload:
|
|
|
|
mock_settings.S3_FILE_STORAGE = True
|
|
mock_s3_upload.side_effect = Exception("S3 upload failed")
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
await FileUploadService.upload_file(
|
|
file_data=sample_file_data,
|
|
org_id=str(uuid4())
|
|
)
|
|
|
|
assert "Failed to upload file" in str(exc_info.value)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_file_local_write_failure(self, sample_file_data):
|
|
"""Test handling of local file write failure"""
|
|
with patch('app.services.file_upload_service.settings') as mock_settings, \
|
|
patch('os.makedirs') as mock_makedirs, \
|
|
patch('builtins.open', create=True) as mock_open:
|
|
|
|
mock_settings.S3_FILE_STORAGE = False
|
|
mock_open.side_effect = OSError("Permission denied")
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
await FileUploadService.upload_file(
|
|
file_data=sample_file_data,
|
|
org_id=str(uuid4())
|
|
)
|
|
|
|
assert "Failed to upload file" in str(exc_info.value)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_file_default_content_type(self):
|
|
"""Test upload with default content type when not provided"""
|
|
content = b"test content"
|
|
file_data = {
|
|
'content': base64.b64encode(content).decode('utf-8'),
|
|
'filename': 'test.txt'
|
|
# No content_type provided - will default to 'application/octet-stream' which is not allowed
|
|
}
|
|
|
|
# Should raise error because default content type is not allowed
|
|
with pytest.raises(ValueError) as exc_info:
|
|
await FileUploadService.upload_file(
|
|
file_data=file_data,
|
|
org_id=str(uuid4())
|
|
)
|
|
|
|
# Error could be extension mismatch or file type not allowed
|
|
error_str = str(exc_info.value).lower()
|
|
assert "not allowed" in error_str or "does not match" in error_str or "file type" in error_str
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_file_folder_structure_user(self, sample_file_data):
|
|
"""Test folder structure for user uploads"""
|
|
org_id = str(uuid4())
|
|
user_id = str(uuid4())
|
|
|
|
with patch('app.services.file_upload_service.settings') as mock_settings, \
|
|
patch('app.services.file_upload_service.upload_file_to_s3') as mock_s3_upload, \
|
|
patch('app.services.file_upload_service.resolve_public_url') as mock_signed_url:
|
|
|
|
mock_settings.S3_FILE_STORAGE = True
|
|
mock_s3_upload.return_value = 'https://s3.amazonaws.com/bucket/file.txt'
|
|
mock_signed_url.return_value = 'https://s3.amazonaws.com/bucket/signed'
|
|
|
|
await FileUploadService.upload_file(
|
|
file_data=sample_file_data,
|
|
org_id=org_id,
|
|
user_id=user_id
|
|
)
|
|
|
|
# Verify folder structure
|
|
call_args = mock_s3_upload.call_args
|
|
assert call_args[1]['folder'] == f'chat_attachments/{org_id}'
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_file_folder_structure_customer(self, sample_file_data):
|
|
"""Test folder structure for customer uploads"""
|
|
org_id = str(uuid4())
|
|
customer_id = str(uuid4())
|
|
|
|
with patch('app.services.file_upload_service.settings') as mock_settings, \
|
|
patch('app.services.file_upload_service.upload_file_to_s3') as mock_s3_upload, \
|
|
patch('app.services.file_upload_service.resolve_public_url') as mock_signed_url:
|
|
|
|
mock_settings.S3_FILE_STORAGE = True
|
|
mock_s3_upload.return_value = 'https://s3.amazonaws.com/bucket/file.txt'
|
|
mock_signed_url.return_value = 'https://s3.amazonaws.com/bucket/signed'
|
|
|
|
await FileUploadService.upload_file(
|
|
file_data=sample_file_data,
|
|
org_id=org_id,
|
|
customer_id=customer_id
|
|
)
|
|
|
|
# Verify folder structure (same as user for now)
|
|
call_args = mock_s3_upload.call_args
|
|
assert call_args[1]['folder'] == f'chat_attachments/{org_id}'
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_file_unique_filename_generation(self, sample_file_data):
|
|
"""Test that unique filenames are generated"""
|
|
org_id = str(uuid4())
|
|
|
|
with patch('app.services.file_upload_service.settings') as mock_settings, \
|
|
patch('app.services.file_upload_service.upload_file_to_s3') as mock_s3_upload, \
|
|
patch('app.services.file_upload_service.resolve_public_url') as mock_signed_url, \
|
|
patch('uuid.uuid4') as mock_uuid:
|
|
|
|
mock_settings.S3_FILE_STORAGE = True
|
|
mock_s3_upload.return_value = 'https://s3.amazonaws.com/bucket/file.txt'
|
|
mock_signed_url.return_value = 'https://s3.amazonaws.com/bucket/signed'
|
|
|
|
# Mock UUID generation
|
|
mock_uuid.return_value = Mock()
|
|
mock_uuid.return_value.__str__ = Mock(return_value='unique-id-789')
|
|
|
|
await FileUploadService.upload_file(
|
|
file_data=sample_file_data,
|
|
org_id=org_id
|
|
)
|
|
|
|
# Verify unique filename was generated
|
|
call_args = mock_s3_upload.call_args
|
|
assert call_args[1]['filename'] == 'unique-id-789.txt'
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_file_no_extension(self):
|
|
"""Test upload with filename that has no extension"""
|
|
content = b"test content"
|
|
file_data = {
|
|
'content': base64.b64encode(content).decode('utf-8'),
|
|
'filename': 'test_file_no_extension',
|
|
'content_type': 'text/plain'
|
|
}
|
|
|
|
# Should raise error because file has no extension
|
|
with pytest.raises(ValueError) as exc_info:
|
|
await FileUploadService.upload_file(
|
|
file_data=file_data,
|
|
org_id=str(uuid4())
|
|
)
|
|
|
|
assert "must have an extension" in str(exc_info.value) or "extension" in str(exc_info.value).lower()
|
|
|
|
|
|
class TestFileUploadServiceConstants:
|
|
"""Test service constants and configurations"""
|
|
|
|
def test_allowed_file_types_combination(self):
|
|
"""Test that ALLOWED_FILE_TYPES is correct combination"""
|
|
expected_types = ALLOWED_IMAGE_TYPES | ALLOWED_DOCUMENT_TYPES
|
|
assert ALLOWED_FILE_TYPES == expected_types
|
|
|
|
def test_file_size_constants(self):
|
|
"""Test file size constants are reasonable"""
|
|
assert MAX_FILE_SIZE == 10 * 1024 * 1024 # 10MB
|
|
assert MAX_IMAGE_SIZE == 5 * 1024 * 1024 # 5MB
|
|
assert MAX_IMAGE_SIZE < MAX_FILE_SIZE # Image limit should be smaller
|
|
|
|
def test_allowed_image_types_content(self):
|
|
"""Test allowed image types include common formats (SVG excluded for XSS safety)"""
|
|
expected_image_types = {
|
|
'image/jpeg', 'image/jpg', 'image/png',
|
|
'image/gif', 'image/webp'
|
|
}
|
|
assert ALLOWED_IMAGE_TYPES == expected_image_types
|
|
# Verify SVG is explicitly NOT included
|
|
assert 'image/svg+xml' not in ALLOWED_IMAGE_TYPES
|
|
|
|
def test_allowed_document_types_content(self):
|
|
"""Test allowed document types include common formats"""
|
|
expected_doc_types = {
|
|
'application/pdf',
|
|
'application/msword',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'text/plain',
|
|
'text/csv',
|
|
'application/vnd.ms-excel',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
}
|
|
assert ALLOWED_DOCUMENT_TYPES == expected_doc_types
|