mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 08:48:10 +08:00
252 lines
9.4 KiB
Python
252 lines
9.4 KiB
Python
from __future__ import annotations
|
|
|
|
import urllib.parse
|
|
from collections.abc import Generator
|
|
from contextlib import contextmanager
|
|
from datetime import UTC, datetime
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from flask import Flask
|
|
|
|
from controllers.common.errors import (
|
|
BlockedFileExtensionError,
|
|
FileTooLargeError,
|
|
RemoteFileAccessDeniedError,
|
|
RemoteFileInvalidResponseError,
|
|
RemoteFileInvalidUrlError,
|
|
RemoteFileNotFoundError,
|
|
RemoteFileUnavailableError,
|
|
RemoteFileUrlBlockedError,
|
|
UnsupportedFileTypeError,
|
|
)
|
|
from controllers.web import remote_files as remote_files_module
|
|
from libs.exception import BaseHTTPException
|
|
from models.enums import EndUserType
|
|
from models.model import App, AppMode, EndUser
|
|
from services.errors.file import (
|
|
BlockedFileExtensionError as ServiceBlockedFileExtensionError,
|
|
)
|
|
from services.errors.file import FileTooLargeError as ServiceFileTooLargeError
|
|
from services.errors.file import UnsupportedFileTypeError as ServiceUnsupportedFileTypeError
|
|
from services.remote_file_service import (
|
|
RemoteFileAccessDeniedError as RemoteFileAccessDeniedServiceError,
|
|
)
|
|
from services.remote_file_service import RemoteFileError, RemoteFileInfoResult, RemoteFileUploadResult
|
|
from services.remote_file_service import (
|
|
RemoteFileInvalidResponseError as RemoteFileInvalidResponseServiceError,
|
|
)
|
|
from services.remote_file_service import RemoteFileInvalidUrlError as RemoteFileInvalidUrlServiceError
|
|
from services.remote_file_service import RemoteFileNotFoundError as RemoteFileNotFoundServiceError
|
|
from services.remote_file_service import RemoteFileUnavailableError as RemoteFileUnavailableServiceError
|
|
from services.remote_file_service import RemoteFileUrlBlockedError as RemoteFileUrlBlockedServiceError
|
|
|
|
REMOTE_FILE_ERROR_CASES: tuple[tuple[type[RemoteFileError], type[BaseHTTPException], str, int], ...] = (
|
|
(RemoteFileInvalidUrlServiceError, RemoteFileInvalidUrlError, "remote_file_invalid_url", 400),
|
|
(RemoteFileUrlBlockedServiceError, RemoteFileUrlBlockedError, "remote_file_url_blocked", 400),
|
|
(RemoteFileNotFoundServiceError, RemoteFileNotFoundError, "remote_file_not_found", 404),
|
|
(RemoteFileAccessDeniedServiceError, RemoteFileAccessDeniedError, "remote_file_access_denied", 400),
|
|
(RemoteFileUnavailableServiceError, RemoteFileUnavailableError, "remote_file_unavailable", 502),
|
|
(
|
|
RemoteFileInvalidResponseServiceError,
|
|
RemoteFileInvalidResponseError,
|
|
"remote_file_invalid_response",
|
|
502,
|
|
),
|
|
)
|
|
|
|
|
|
def _app_model() -> App:
|
|
return App(
|
|
id="app-1",
|
|
tenant_id="tenant-1",
|
|
name="Web App",
|
|
description="",
|
|
mode=AppMode.CHAT,
|
|
enable_site=True,
|
|
enable_api=True,
|
|
max_active_requests=0,
|
|
)
|
|
|
|
|
|
def _end_user() -> EndUser:
|
|
return EndUser(
|
|
id="eu-1",
|
|
tenant_id="tenant-1",
|
|
app_id="app-1",
|
|
type=EndUserType.BROWSER,
|
|
session_id="session-1",
|
|
)
|
|
|
|
|
|
def _upload_result() -> RemoteFileUploadResult:
|
|
return RemoteFileUploadResult(
|
|
id="file-1",
|
|
name="report.txt",
|
|
size=16,
|
|
extension="txt",
|
|
url="https://signed.example/file-1",
|
|
mime_type="text/plain",
|
|
created_by="eu-1",
|
|
created_at=datetime(2024, 1, 1, tzinfo=UTC),
|
|
)
|
|
|
|
|
|
@contextmanager
|
|
def _patch_application_services(remote_files: MagicMock) -> Generator[None]:
|
|
with patch.object(
|
|
remote_files_module,
|
|
"application_services",
|
|
return_value=SimpleNamespace(remote_files=remote_files),
|
|
):
|
|
yield
|
|
|
|
|
|
class TestRemoteFileInfoApi:
|
|
def test_decodes_url_and_preserves_web_missing_length_default(self, app: Flask) -> None:
|
|
remote_files = MagicMock()
|
|
remote_files.fetch_info.return_value = RemoteFileInfoResult(
|
|
content_type="text/plain",
|
|
content_length=None,
|
|
)
|
|
target_url = "http://example.com/api/file?name=report.txt"
|
|
encoded_url = urllib.parse.quote(target_url, safe="")
|
|
|
|
with (
|
|
app.test_request_context(f"/remote-files/{encoded_url}"),
|
|
_patch_application_services(remote_files),
|
|
):
|
|
result = remote_files_module.RemoteFileInfoApi().get(_app_model(), _end_user(), encoded_url)
|
|
|
|
assert result == {"file_type": "text/plain", "file_length": -1}
|
|
remote_files.fetch_info.assert_called_once_with(url=target_url)
|
|
|
|
@pytest.mark.parametrize(("service_error_type", "http_error", "error_code", "status"), REMOTE_FILE_ERROR_CASES)
|
|
def test_translates_remote_file_errors(
|
|
self,
|
|
app: Flask,
|
|
service_error_type: type[RemoteFileError],
|
|
http_error: type[BaseHTTPException],
|
|
error_code: str,
|
|
status: int,
|
|
) -> None:
|
|
service_error = service_error_type("sensitive upstream details")
|
|
remote_files = MagicMock()
|
|
remote_files.fetch_info.side_effect = service_error
|
|
|
|
with (
|
|
app.test_request_context("/remote-files/url"),
|
|
_patch_application_services(remote_files),
|
|
pytest.raises(http_error) as error_info,
|
|
):
|
|
remote_files_module.RemoteFileInfoApi().get(_app_model(), _end_user(), "url")
|
|
|
|
assert error_info.value.__cause__ is service_error
|
|
assert error_info.value.data is not None
|
|
assert error_info.value.data["code"] == error_code
|
|
assert error_info.value.data["status"] == status
|
|
assert "sensitive upstream details" not in error_info.value.data["message"]
|
|
|
|
|
|
class TestRemoteFileUploadApi:
|
|
def test_upload_delegates_without_overriding_web_tenant(self, app: Flask) -> None:
|
|
remote_files = MagicMock()
|
|
remote_files.upload_from_url.return_value = _upload_result()
|
|
end_user = _end_user()
|
|
url = "https://example.com/report.txt"
|
|
|
|
with (
|
|
app.test_request_context("/remote-files/upload", method="POST", json={"url": url}),
|
|
_patch_application_services(remote_files),
|
|
):
|
|
response, status = remote_files_module.RemoteFileUploadApi().post(_app_model(), end_user)
|
|
|
|
assert status == 201
|
|
assert response == {
|
|
"id": "file-1",
|
|
"name": "report.txt",
|
|
"size": 16,
|
|
"extension": "txt",
|
|
"url": "https://signed.example/file-1",
|
|
"mime_type": "text/plain",
|
|
"created_by": "eu-1",
|
|
"created_at": 1704067200,
|
|
}
|
|
remote_files.upload_from_url.assert_called_once_with(url=url, user=end_user)
|
|
|
|
def test_uses_complete_message_for_empty_file_size_error(self, app: Flask) -> None:
|
|
remote_files = MagicMock()
|
|
remote_files.upload_from_url.side_effect = ServiceFileTooLargeError()
|
|
|
|
with (
|
|
app.test_request_context(
|
|
"/remote-files/upload",
|
|
method="POST",
|
|
json={"url": "https://example.com/report.txt"},
|
|
),
|
|
_patch_application_services(remote_files),
|
|
pytest.raises(FileTooLargeError) as error_info,
|
|
):
|
|
remote_files_module.RemoteFileUploadApi().post(_app_model(), _end_user())
|
|
|
|
assert error_info.value.description == "File size exceeded."
|
|
|
|
@pytest.mark.parametrize(("service_error_type", "http_error", "error_code", "status"), REMOTE_FILE_ERROR_CASES)
|
|
def test_translates_remote_file_errors(
|
|
self,
|
|
app: Flask,
|
|
service_error_type: type[RemoteFileError],
|
|
http_error: type[BaseHTTPException],
|
|
error_code: str,
|
|
status: int,
|
|
) -> None:
|
|
service_error = service_error_type("sensitive upstream details")
|
|
remote_files = MagicMock()
|
|
remote_files.upload_from_url.side_effect = service_error
|
|
|
|
with (
|
|
app.test_request_context(
|
|
"/remote-files/upload",
|
|
method="POST",
|
|
json={"url": "https://example.com/report.txt"},
|
|
),
|
|
_patch_application_services(remote_files),
|
|
pytest.raises(http_error) as error_info,
|
|
):
|
|
remote_files_module.RemoteFileUploadApi().post(_app_model(), _end_user())
|
|
|
|
assert error_info.value.__cause__ is service_error
|
|
assert error_info.value.data is not None
|
|
assert error_info.value.data["code"] == error_code
|
|
assert error_info.value.data["status"] == status
|
|
assert "sensitive upstream details" not in error_info.value.data["message"]
|
|
|
|
@pytest.mark.parametrize(
|
|
("service_error", "http_error"),
|
|
[
|
|
(ServiceFileTooLargeError("size exceeded"), FileTooLargeError),
|
|
(ServiceUnsupportedFileTypeError(), UnsupportedFileTypeError),
|
|
(
|
|
ServiceBlockedFileExtensionError("File extension '.exe' is not allowed"),
|
|
BlockedFileExtensionError,
|
|
),
|
|
],
|
|
)
|
|
def test_translates_file_errors(self, app: Flask, service_error: Exception, http_error: type[Exception]) -> None:
|
|
remote_files = MagicMock()
|
|
remote_files.upload_from_url.side_effect = service_error
|
|
|
|
with (
|
|
app.test_request_context(
|
|
"/remote-files/upload",
|
|
method="POST",
|
|
json={"url": "https://example.com/report.txt"},
|
|
),
|
|
_patch_application_services(remote_files),
|
|
pytest.raises(http_error) as error_info,
|
|
):
|
|
remote_files_module.RemoteFileUploadApi().post(_app_model(), _end_user())
|
|
|
|
assert error_info.value.__cause__ is service_error
|