mirror of
https://github.com/langgenius/dify.git
synced 2026-11-12 06:11:12 +08:00
581 lines
22 KiB
Python
581 lines
22 KiB
Python
import builtins
|
|
import io
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from flask import Flask
|
|
from flask.views import MethodView
|
|
from werkzeug.exceptions import Forbidden
|
|
|
|
from configs import dify_config
|
|
from constants import DOCUMENT_EXTENSIONS
|
|
from controllers.common.errors import (
|
|
BlockedFileExtensionError,
|
|
FilenameNotExistsError,
|
|
FileTooLargeError,
|
|
NoFileUploadedError,
|
|
TooManyFilesError,
|
|
UnsupportedFileTypeError,
|
|
)
|
|
from controllers.console.files import (
|
|
FileApi,
|
|
FilePreviewApi,
|
|
FileSupportTypeApi,
|
|
upload_file_from_request,
|
|
)
|
|
from machinery.context import RequestContext
|
|
from models import Account
|
|
from models.account import AccountStatus, TenantAccountRole
|
|
from models.model import UploadFile
|
|
from tests.helpers.unit.model_factories import make_upload_file
|
|
|
|
|
|
def unwrap(func):
|
|
"""
|
|
Recursively unwrap decorated functions.
|
|
"""
|
|
while hasattr(func, "__wrapped__"):
|
|
func = func.__wrapped__
|
|
return func
|
|
|
|
|
|
def _upload_file(*, file_id: str = "file-id-123", size: int = 1024) -> UploadFile:
|
|
return make_upload_file(
|
|
file_id=file_id,
|
|
tenant_id="tenant-123",
|
|
key=f"upload/{file_id}/test.txt",
|
|
size=size,
|
|
created_by="user-123",
|
|
)
|
|
|
|
|
|
def _request_context(*, workspace_id: str = "tenant-1") -> RequestContext:
|
|
return RequestContext(
|
|
request_id="request-1",
|
|
trace_id="trace-1",
|
|
account_id="account-1",
|
|
active_workspace_id=workspace_id,
|
|
)
|
|
|
|
|
|
class TestFilesCases:
|
|
@staticmethod
|
|
@pytest.fixture
|
|
def app():
|
|
app = Flask(__name__)
|
|
app.testing = True
|
|
return app
|
|
|
|
@staticmethod
|
|
@pytest.fixture(autouse=True)
|
|
def mock_decorators():
|
|
"""
|
|
Make decorators no-ops so logic is directly testable
|
|
"""
|
|
with (
|
|
patch("controllers.console.files.setup_required", new=lambda f: f),
|
|
patch("controllers.console.files.login_required", new=lambda f: f),
|
|
patch("controllers.console.files.account_initialization_required", new=lambda f: f),
|
|
patch("controllers.console.files.cloud_edition_billing_resource_check", return_value=lambda f: f),
|
|
):
|
|
yield
|
|
|
|
@staticmethod
|
|
@pytest.fixture
|
|
def mock_current_user():
|
|
user = Account(name="Test User", email="user-1@example.com", status=AccountStatus.ACTIVE)
|
|
user.id = "user-1"
|
|
user.role = TenantAccountRole.OWNER
|
|
return user
|
|
|
|
@staticmethod
|
|
@pytest.fixture
|
|
def mock_account_context(mock_current_user):
|
|
return mock_current_user
|
|
|
|
@staticmethod
|
|
@pytest.fixture
|
|
def mock_file_service():
|
|
with patch("controllers.console.files.application_services") as services:
|
|
yield services.return_value.files
|
|
|
|
class TestFileApiGet:
|
|
def test_get_upload_config(self, app: Flask):
|
|
api = FileApi()
|
|
get_method = unwrap(api.get)
|
|
|
|
with (
|
|
app.test_request_context(),
|
|
patch(
|
|
"controllers.console.files.FeatureService.get_knowledge_file_size_limit",
|
|
return_value=50,
|
|
) as get_knowledge_file_size_limit,
|
|
):
|
|
data, status = get_method(api, _request_context())
|
|
|
|
assert status == 200
|
|
assert "file_size_limit" in data
|
|
assert data["knowledge_file_size_limit"] == 50
|
|
assert "batch_count_limit" in data
|
|
get_knowledge_file_size_limit.assert_called_once_with("tenant-1")
|
|
assert data["skill_file_size_limit"] == dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT
|
|
|
|
class TestFileApiPost:
|
|
def test_no_file_uploaded(self, app: Flask, mock_account_context):
|
|
api = FileApi()
|
|
post_method = unwrap(api.post)
|
|
|
|
with app.test_request_context(method="POST", data={}):
|
|
with pytest.raises(NoFileUploadedError):
|
|
post_method(api, mock_account_context)
|
|
|
|
def test_too_many_files(self, app: Flask, mock_account_context):
|
|
api = FileApi()
|
|
post_method = unwrap(api.post)
|
|
|
|
with app.test_request_context(method="POST"):
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
with patch("controllers.console.files.request") as mock_request:
|
|
mock_request.files = MagicMock()
|
|
mock_request.files.__len__.return_value = 2
|
|
mock_request.files.__contains__.return_value = True
|
|
mock_request.form = MagicMock()
|
|
mock_request.form.get.return_value = None
|
|
|
|
with pytest.raises(TooManyFilesError):
|
|
post_method(api, mock_account_context)
|
|
|
|
def test_filename_missing(self, app: Flask, mock_account_context):
|
|
api = FileApi()
|
|
post_method = unwrap(api.post)
|
|
|
|
data = {
|
|
"file": (io.BytesIO(b"abc"), ""),
|
|
}
|
|
|
|
with app.test_request_context(method="POST", data=data):
|
|
with pytest.raises(FilenameNotExistsError):
|
|
post_method(api, mock_account_context)
|
|
|
|
def test_dataset_upload_without_permission(self, app: Flask, mock_current_user):
|
|
mock_current_user.role = TenantAccountRole.NORMAL
|
|
|
|
api = FileApi()
|
|
post_method = unwrap(api.post)
|
|
|
|
data = {
|
|
"file": (io.BytesIO(b"abc"), "test.txt"),
|
|
"source": "datasets",
|
|
}
|
|
|
|
with app.test_request_context(method="POST", data=data):
|
|
with pytest.raises(Forbidden):
|
|
post_method(api, mock_current_user)
|
|
|
|
def test_successful_upload(self, app: Flask, mock_account_context, mock_file_service):
|
|
api = FileApi()
|
|
post_method = unwrap(api.post)
|
|
|
|
mock_file_service.upload_file.return_value = _upload_file()
|
|
|
|
data = {
|
|
"file": (io.BytesIO(b"hello"), "test.txt"),
|
|
}
|
|
|
|
with app.test_request_context(method="POST", data=data):
|
|
response, status = post_method(api, mock_account_context)
|
|
|
|
assert status == 201
|
|
assert response["id"] == "file-id-123"
|
|
assert response["name"] == "test.txt"
|
|
mock_file_service.upload_file.assert_called_once_with(
|
|
filename="test.txt",
|
|
content=b"hello",
|
|
mimetype="text/plain",
|
|
user=mock_account_context,
|
|
tenant_id=None,
|
|
source=None,
|
|
default_file_size_limit=None,
|
|
)
|
|
|
|
def test_upload_with_resource_tenant(self, app: Flask, mock_account_context, mock_file_service):
|
|
upload_file = _upload_file()
|
|
mock_file_service.upload_file.return_value = upload_file
|
|
|
|
with app.test_request_context(
|
|
method="POST",
|
|
data={"file": (io.BytesIO(b"hello"), "test.txt")},
|
|
):
|
|
result = upload_file_from_request(
|
|
current_user=mock_account_context,
|
|
resource_tenant_id="app-tenant-id",
|
|
)
|
|
|
|
assert result is upload_file
|
|
assert mock_file_service.upload_file.call_args.kwargs["tenant_id"] == "app-tenant-id"
|
|
|
|
def test_dataset_source_from_query_uses_knowledge_limit(
|
|
self,
|
|
app: Flask,
|
|
mock_account_context,
|
|
mock_file_service,
|
|
):
|
|
upload_file = _upload_file()
|
|
mock_file_service.upload_file.return_value = upload_file
|
|
|
|
with (
|
|
app.test_request_context(
|
|
"/?source=datasets",
|
|
method="POST",
|
|
data={"file": (io.BytesIO(b"hello"), "test.txt")},
|
|
),
|
|
patch(
|
|
"controllers.console.files.FeatureService.get_knowledge_file_size_limit",
|
|
return_value=50,
|
|
) as get_knowledge_file_size_limit,
|
|
):
|
|
result = upload_file_from_request(current_user=mock_account_context)
|
|
|
|
assert result is upload_file
|
|
assert mock_file_service.upload_file.call_args.kwargs["source"] == "datasets"
|
|
assert mock_file_service.upload_file.call_args.kwargs["default_file_size_limit"] == 50
|
|
get_knowledge_file_size_limit.assert_called_once_with(mock_account_context.current_tenant_id)
|
|
|
|
def test_upload_with_invalid_source(self, app: Flask, mock_account_context, mock_file_service):
|
|
"""Test that invalid source parameter gets normalized to None"""
|
|
api = FileApi()
|
|
post_method = unwrap(api.post)
|
|
|
|
mock_file_service.upload_file.return_value = _upload_file(file_id="file-id-456", size=512)
|
|
|
|
data = {
|
|
"file": (io.BytesIO(b"content"), "test.txt"),
|
|
"source": "invalid_source", # Should be normalized to None
|
|
}
|
|
|
|
with app.test_request_context(method="POST", data=data):
|
|
response, status = post_method(api, mock_account_context)
|
|
|
|
assert status == 201
|
|
assert response["id"] == "file-id-456"
|
|
# Verify that FileService was called with source=None
|
|
mock_file_service.upload_file.assert_called_once()
|
|
call_kwargs = mock_file_service.upload_file.call_args[1]
|
|
assert call_kwargs["source"] is None
|
|
|
|
def test_file_too_large_error(self, app: Flask, mock_account_context, mock_file_service):
|
|
api = FileApi()
|
|
post_method = unwrap(api.post)
|
|
|
|
from services.errors.file import FileTooLargeError as ServiceFileTooLargeError
|
|
|
|
error = ServiceFileTooLargeError("File is too large")
|
|
mock_file_service.upload_file.side_effect = error
|
|
|
|
data = {
|
|
"file": (io.BytesIO(b"x" * 1000000), "big.txt"),
|
|
}
|
|
|
|
with app.test_request_context(method="POST", data=data):
|
|
with pytest.raises(FileTooLargeError) as error_info:
|
|
post_method(api, mock_account_context)
|
|
|
|
assert error_info.value.__cause__ is error
|
|
|
|
def test_unsupported_file_type(self, app: Flask, mock_account_context, mock_file_service):
|
|
api = FileApi()
|
|
post_method = unwrap(api.post)
|
|
|
|
from services.errors.file import UnsupportedFileTypeError as ServiceUnsupportedFileTypeError
|
|
|
|
error = ServiceUnsupportedFileTypeError()
|
|
mock_file_service.upload_file.side_effect = error
|
|
|
|
data = {
|
|
"file": (io.BytesIO(b"x"), "bad.exe"),
|
|
}
|
|
|
|
with app.test_request_context(method="POST", data=data):
|
|
with pytest.raises(UnsupportedFileTypeError) as error_info:
|
|
post_method(api, mock_account_context)
|
|
|
|
assert error_info.value.__cause__ is error
|
|
|
|
def test_blocked_extension(self, app: Flask, mock_account_context, mock_file_service):
|
|
api = FileApi()
|
|
post_method = unwrap(api.post)
|
|
|
|
from services.errors.file import BlockedFileExtensionError as ServiceBlockedFileExtensionError
|
|
|
|
error = ServiceBlockedFileExtensionError("File extension is blocked")
|
|
mock_file_service.upload_file.side_effect = error
|
|
|
|
data = {
|
|
"file": (io.BytesIO(b"x"), "blocked.txt"),
|
|
}
|
|
|
|
with app.test_request_context(method="POST", data=data):
|
|
with pytest.raises(BlockedFileExtensionError) as error_info:
|
|
post_method(api, mock_account_context)
|
|
|
|
assert error_info.value.description == error.description
|
|
assert error_info.value.__cause__ is error
|
|
|
|
class TestFilePreviewApi:
|
|
def test_get_preview(self, app: Flask, mock_account_context, mock_file_service):
|
|
api = FilePreviewApi()
|
|
get_method = unwrap(api.get)
|
|
mock_file_service.get_file_preview.return_value = "preview text"
|
|
|
|
with app.test_request_context():
|
|
result = get_method(api, _request_context(workspace_id="tenant-123"), "1234")
|
|
|
|
assert result == {"content": "preview text"}
|
|
mock_file_service.get_file_preview.assert_called_once_with(file_id="1234", tenant_id="tenant-123")
|
|
|
|
class TestFileSupportTypeApi:
|
|
def test_get_supported_types(self, app: Flask):
|
|
api = FileSupportTypeApi()
|
|
get_method = unwrap(api.get)
|
|
|
|
with app.test_request_context():
|
|
result = get_method(api, _request_context())
|
|
|
|
assert result == {"allowed_extensions": list(DOCUMENT_EXTENSIONS)}
|
|
|
|
|
|
if not hasattr(builtins, "MethodView"):
|
|
builtins.MethodView = MethodView # type: ignore[attr-defined]
|
|
|
|
|
|
class TestFilesSecurityCases:
|
|
class TestFileUploadSecurity:
|
|
"""Test file upload security logic without complex framework setup"""
|
|
|
|
# Test 1: Basic file validation
|
|
def test_should_validate_file_presence(self) -> None:
|
|
"""Test that missing file is detected"""
|
|
from flask import Flask, request
|
|
|
|
app = Flask(__name__)
|
|
|
|
with app.test_request_context(method="POST", data={}):
|
|
# Simulate the check in FileApi.post()
|
|
if "file" not in request.files:
|
|
with pytest.raises(NoFileUploadedError):
|
|
raise NoFileUploadedError()
|
|
|
|
def test_should_validate_multiple_files(self) -> None:
|
|
"""Test that multiple files are rejected"""
|
|
from flask import Flask, request
|
|
|
|
app = Flask(__name__)
|
|
|
|
file_data = {
|
|
"file": (io.BytesIO(b"content1"), "file1.txt", "text/plain"),
|
|
"file2": (io.BytesIO(b"content2"), "file2.txt", "text/plain"),
|
|
}
|
|
|
|
with app.test_request_context(method="POST", data=file_data, content_type="multipart/form-data"):
|
|
# Simulate the check in FileApi.post()
|
|
if len(request.files) > 1:
|
|
with pytest.raises(TooManyFilesError):
|
|
raise TooManyFilesError()
|
|
|
|
def test_should_validate_empty_filename(self) -> None:
|
|
"""Test that empty filename is rejected"""
|
|
from flask import Flask, request
|
|
|
|
app = Flask(__name__)
|
|
|
|
file_data = {"file": (io.BytesIO(b"content"), "", "text/plain")}
|
|
|
|
with app.test_request_context(method="POST", data=file_data, content_type="multipart/form-data"):
|
|
file = request.files["file"]
|
|
if not file.filename:
|
|
with pytest.raises(FilenameNotExistsError):
|
|
raise FilenameNotExistsError
|
|
|
|
# Test 2: Security - Filename sanitization
|
|
def test_should_detect_path_traversal_in_filename(self) -> None:
|
|
"""Test protection against directory traversal attacks"""
|
|
dangerous_filenames = [
|
|
"../../../etc/passwd",
|
|
"..\\..\\windows\\system32\\config\\sam",
|
|
"../../../../etc/shadow",
|
|
"./../../../sensitive.txt",
|
|
]
|
|
|
|
for filename in dangerous_filenames:
|
|
# Any filename containing .. should be considered dangerous
|
|
assert ".." in filename, f"Filename {filename} should be detected as path traversal"
|
|
|
|
def test_should_detect_null_byte_injection(self) -> None:
|
|
"""Test protection against null byte injection"""
|
|
dangerous_filenames = [
|
|
"file.jpg\x00.php",
|
|
"document.pdf\x00.exe",
|
|
"image.png\x00.sh",
|
|
]
|
|
|
|
for filename in dangerous_filenames:
|
|
# Null bytes should be detected
|
|
assert "\x00" in filename, f"Filename {filename} should be detected as null byte injection"
|
|
|
|
def test_should_sanitize_special_characters(self) -> None:
|
|
"""Test that special characters in filenames are handled safely"""
|
|
# Characters that could be problematic in various contexts
|
|
dangerous_chars = ["/", "\\", ":", "*", "?", '"', "<", ">", "|", "\x00"]
|
|
|
|
for char in dangerous_chars:
|
|
filename = f"file{char}name.txt"
|
|
# These characters should be detected or sanitized
|
|
assert any(c in filename for c in dangerous_chars)
|
|
|
|
# Test 3: Permission validation
|
|
def test_should_validate_dataset_permissions(self) -> None:
|
|
"""Test dataset upload permission logic"""
|
|
user = Account(name="Viewer", email="viewer@example.com", status=AccountStatus.ACTIVE)
|
|
user.role = TenantAccountRole.NORMAL
|
|
source = "datasets"
|
|
|
|
# Simulate the permission check in FileApi.post()
|
|
if source == "datasets" and not user.is_dataset_editor:
|
|
with pytest.raises(Forbidden):
|
|
raise Forbidden()
|
|
|
|
def test_should_allow_general_upload_without_permission(self) -> None:
|
|
"""Test general upload doesn't require dataset permission"""
|
|
user = Account(name="Viewer", email="viewer@example.com", status=AccountStatus.ACTIVE)
|
|
user.role = TenantAccountRole.NORMAL
|
|
source = None # General upload
|
|
|
|
# This should not raise an exception
|
|
if source == "datasets" and not user.is_dataset_editor:
|
|
raise Forbidden()
|
|
# Test passes if no exception is raised
|
|
|
|
# Test 4: File type security
|
|
def test_should_identify_dangerous_file_extensions(self) -> None:
|
|
"""Test detection of potentially dangerous file extensions"""
|
|
dangerous_extensions = [
|
|
".php",
|
|
".PHP",
|
|
".pHp", # PHP files (case variations)
|
|
".exe",
|
|
".EXE", # Executables
|
|
".sh",
|
|
".SH", # Shell scripts
|
|
".bat",
|
|
".BAT", # Batch files
|
|
".cmd",
|
|
".CMD", # Command files
|
|
".ps1",
|
|
".PS1", # PowerShell
|
|
".jar",
|
|
".JAR", # Java archives
|
|
".vbs",
|
|
".VBS", # VBScript
|
|
]
|
|
|
|
safe_extensions = [".txt", ".pdf", ".jpg", ".png", ".doc", ".docx"]
|
|
|
|
# Just verify our test data is correct
|
|
for ext in dangerous_extensions:
|
|
assert ext.lower() in [".php", ".exe", ".sh", ".bat", ".cmd", ".ps1", ".jar", ".vbs"]
|
|
|
|
for ext in safe_extensions:
|
|
assert ext.lower() not in [".php", ".exe", ".sh", ".bat", ".cmd", ".ps1", ".jar", ".vbs"]
|
|
|
|
def test_should_detect_double_extensions(self) -> None:
|
|
"""Test detection of double extension attacks"""
|
|
suspicious_filenames = [
|
|
"image.jpg.php",
|
|
"document.pdf.exe",
|
|
"photo.png.sh",
|
|
"file.txt.bat",
|
|
]
|
|
|
|
for filename in suspicious_filenames:
|
|
# Check that these have multiple extensions
|
|
parts = filename.split(".")
|
|
assert len(parts) > 2, f"Filename {filename} should have multiple extensions"
|
|
|
|
# Test 5: Configuration validation
|
|
def test_upload_configuration_structure(self) -> None:
|
|
"""Test that upload configuration has correct structure"""
|
|
# Simulate the configuration returned by FileApi.get()
|
|
config = {
|
|
"file_size_limit": 15,
|
|
"batch_count_limit": 5,
|
|
"image_file_size_limit": 10,
|
|
"video_file_size_limit": 500,
|
|
"audio_file_size_limit": 50,
|
|
"workflow_file_upload_limit": 10,
|
|
}
|
|
|
|
# Verify all required fields are present
|
|
required_fields = [
|
|
"file_size_limit",
|
|
"batch_count_limit",
|
|
"image_file_size_limit",
|
|
"video_file_size_limit",
|
|
"audio_file_size_limit",
|
|
"workflow_file_upload_limit",
|
|
]
|
|
|
|
for field in required_fields:
|
|
assert field in config, f"Missing required field: {field}"
|
|
assert isinstance(config[field], int), f"Field {field} should be an integer"
|
|
assert config[field] > 0, f"Field {field} should be positive"
|
|
|
|
# Test 7: Source parameter handling
|
|
def test_source_parameter_normalization(self) -> None:
|
|
"""Test that source parameter is properly normalized"""
|
|
test_cases = [
|
|
("datasets", "datasets"),
|
|
("other", None),
|
|
("", None),
|
|
(None, None),
|
|
]
|
|
|
|
for input_source, expected in test_cases:
|
|
# Simulate the source normalization in FileApi.post()
|
|
source = "datasets" if input_source == "datasets" else None
|
|
if source not in ("datasets", None):
|
|
source = None
|
|
assert source == expected
|
|
|
|
# Test 8: Boundary conditions
|
|
def test_should_handle_edge_case_file_sizes(self) -> None:
|
|
"""Test handling of boundary file sizes"""
|
|
test_cases = [
|
|
(0, "Empty file"), # 0 bytes
|
|
(1, "Single byte"), # 1 byte
|
|
(15 * 1024 * 1024 - 1, "Just under limit"), # Just under 15MB
|
|
(15 * 1024 * 1024, "At limit"), # Exactly 15MB
|
|
(15 * 1024 * 1024 + 1, "Just over limit"), # Just over 15MB
|
|
]
|
|
|
|
for size, description in test_cases:
|
|
# Just verify our test data
|
|
assert isinstance(size, int), f"{description}: Size should be integer"
|
|
assert size >= 0, f"{description}: Size should be non-negative"
|
|
|
|
def test_should_handle_special_mime_types(self) -> None:
|
|
"""Test handling of various MIME types"""
|
|
mime_type_tests = [
|
|
("application/octet-stream", "Generic binary"),
|
|
("text/plain", "Plain text"),
|
|
("image/jpeg", "JPEG image"),
|
|
("application/pdf", "PDF document"),
|
|
("", "Empty MIME type"),
|
|
(None, "None MIME type"),
|
|
]
|
|
|
|
for mime_type, description in mime_type_tests:
|
|
# Verify test data structure
|
|
if mime_type is not None:
|
|
assert isinstance(mime_type, str), f"{description}: MIME type should be string or None"
|