mirror of
https://github.com/langgenius/dify.git
synced 2026-11-14 07:11:06 +08:00
762 lines
34 KiB
Python
762 lines
34 KiB
Python
import base64
|
|
import hashlib
|
|
import os
|
|
from collections.abc import Callable, Iterator
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
from unittest.mock import patch
|
|
|
|
import httpx
|
|
import pytest
|
|
from sqlalchemy import Connection, Engine, event, select
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
from werkzeug.exceptions import NotFound
|
|
|
|
from core.file import remote_fetcher
|
|
from enums import DeploymentEdition
|
|
from extensions.ext_storage import storage
|
|
from extensions.storage.storage_type import StorageType
|
|
from graphon.file import helpers as file_helpers
|
|
from models.base import TypeBase
|
|
from models.enums import CreatorUserRole
|
|
from models.model import Account, EndUser, UploadFile
|
|
from services.errors.file import (
|
|
BlockedFileExtensionError,
|
|
FileNotExistsError,
|
|
FileTooLargeError,
|
|
UnsupportedFileTypeError,
|
|
)
|
|
from services.file_service import FileService, FileUploadActor, FileUploadResult
|
|
from services.remote_file_service import RemoteFileService
|
|
from tests.helpers.unit.model_factories import make_account
|
|
|
|
|
|
def _account() -> Account:
|
|
return make_account(account_id="user_id", name="Test Account", email="test@example.com")
|
|
|
|
|
|
class TestFileServiceCases:
|
|
class TestFileService:
|
|
@pytest.fixture
|
|
def sqlite_session_maker(self, sqlite_engine: Engine) -> sessionmaker[Session]:
|
|
TypeBase.metadata.create_all(sqlite_engine, tables=[TypeBase.metadata.tables[UploadFile.__tablename__]])
|
|
return sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
|
|
|
@pytest.fixture
|
|
def db_session(self, sqlite_session_maker: sessionmaker[Session]) -> Iterator[Session]:
|
|
with sqlite_session_maker() as session:
|
|
yield session
|
|
|
|
@pytest.fixture
|
|
def file_service(self, sqlite_session_maker: sessionmaker[Session]) -> FileService:
|
|
return FileService(session_factory=sqlite_session_maker)
|
|
|
|
@staticmethod
|
|
def _persist_upload_file(
|
|
session: Session,
|
|
*,
|
|
file_id: str = "file_id",
|
|
tenant_id: str = "tenant_id",
|
|
extension: str = "txt",
|
|
mime_type: str = "text/plain",
|
|
key: str = "key",
|
|
) -> UploadFile:
|
|
upload_file = UploadFile(
|
|
tenant_id=tenant_id,
|
|
storage_type=StorageType.LOCAL,
|
|
key=key,
|
|
name=f"test.{extension}",
|
|
size=10,
|
|
extension=extension,
|
|
mime_type=mime_type,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by="user_id",
|
|
created_at=datetime(2024, 1, 1, tzinfo=UTC),
|
|
used=False,
|
|
)
|
|
upload_file.id = file_id
|
|
session.add(upload_file)
|
|
session.commit()
|
|
return upload_file
|
|
|
|
def test_init_with_engine(self, sqlite_engine: Engine):
|
|
service = FileService(session_factory=sqlite_engine)
|
|
assert isinstance(service._session_maker, sessionmaker)
|
|
|
|
def test_init_with_sessionmaker(self, sqlite_session_maker: sessionmaker[Session]):
|
|
service = FileService(session_factory=sqlite_session_maker)
|
|
assert service._session_maker == sqlite_session_maker
|
|
|
|
def test_init_invalid_factory(self):
|
|
with pytest.raises(AssertionError, match="must be a sessionmaker or an Engine."):
|
|
FileService(session_factory="invalid")
|
|
|
|
@patch("services.file_service.storage")
|
|
@patch("services.file_service.naive_utc_now")
|
|
@patch("services.file_service.extract_tenant_id")
|
|
@patch("services.file_service.file_helpers.get_signed_file_url")
|
|
def test_upload_file_success(
|
|
self, mock_get_url, mock_tenant_id, mock_now, mock_storage, file_service: FileService, db_session: Session
|
|
):
|
|
# Setup
|
|
mock_tenant_id.return_value = "tenant_id"
|
|
mock_now.return_value = datetime(2024, 1, 1, tzinfo=UTC)
|
|
mock_get_url.return_value = "http://signed-url"
|
|
|
|
user = Account(name="Test Account", email="test@example.com")
|
|
user.id = "user_id"
|
|
content = b"file content"
|
|
filename = "test.jpg"
|
|
mimetype = "image/jpeg"
|
|
|
|
# Execute
|
|
result = file_service.upload_file(filename=filename, content=content, mimetype=mimetype, user=user)
|
|
|
|
# Assert
|
|
assert isinstance(result, UploadFile)
|
|
assert result.name == filename
|
|
assert result.tenant_id == "tenant_id"
|
|
assert result.size == len(content)
|
|
assert result.extension == "jpg"
|
|
assert result.mime_type == mimetype
|
|
assert result.created_by_role == CreatorUserRole.ACCOUNT
|
|
assert result.created_by == "user_id"
|
|
assert result.hash == hashlib.sha3_256(content).hexdigest()
|
|
assert result.source_url == "http://signed-url"
|
|
|
|
mock_storage.save.assert_called_once()
|
|
persisted = db_session.get(UploadFile, result.id)
|
|
assert persisted is not None
|
|
assert persisted.hash == result.hash
|
|
|
|
@pytest.mark.parametrize("text", ["ASCII text", "包含多字节 UTF-8 文本 🚀"])
|
|
def test_upload_text_uses_utf8_byte_length(self, text: str, file_service: FileService):
|
|
with patch("services.file_service.storage") as mock_storage:
|
|
result = file_service.upload_text(
|
|
text=text,
|
|
text_name="test.txt",
|
|
user_id="user_id",
|
|
tenant_id="tenant_id",
|
|
)
|
|
|
|
expected_content = text.encode("utf-8")
|
|
assert result.size == len(expected_content)
|
|
mock_storage.save.assert_called_once_with(result.key, expected_content)
|
|
|
|
def test_upload_file_uses_explicit_resource_tenant(self, file_service: FileService):
|
|
user = Account(name="Test Account", email="test@example.com")
|
|
user.id = "user-id"
|
|
|
|
with (
|
|
patch("services.file_service.storage") as mock_storage,
|
|
patch("services.file_service.extract_tenant_id") as mock_extract_tenant_id,
|
|
patch("services.file_service.file_helpers.get_signed_file_url"),
|
|
):
|
|
result = file_service.upload_file(
|
|
filename="test.txt",
|
|
content=b"test",
|
|
mimetype="text/plain",
|
|
user=user,
|
|
tenant_id="resource-tenant-id",
|
|
)
|
|
|
|
assert result.tenant_id == "resource-tenant-id"
|
|
assert mock_storage.save.call_args.args[0].startswith("upload_files/resource-tenant-id/")
|
|
mock_extract_tenant_id.assert_not_called()
|
|
|
|
def test_upload_file_invalid_characters(self, file_service):
|
|
with pytest.raises(ValueError, match="Filename contains invalid characters"):
|
|
file_service.upload_file(
|
|
filename="invalid/file.txt", content=b"", mimetype="text/plain", user=_account()
|
|
)
|
|
|
|
def test_upload_file_long_filename(self, file_service: FileService, db_session: Session):
|
|
# Setup
|
|
long_name = "a" * 210 + ".txt"
|
|
user = Account(name="Test Account", email="test@example.com")
|
|
user.id = "user_id"
|
|
|
|
with (
|
|
patch("services.file_service.storage"),
|
|
patch("services.file_service.extract_tenant_id") as mock_tenant,
|
|
patch("services.file_service.file_helpers.get_signed_file_url"),
|
|
):
|
|
mock_tenant.return_value = "tenant"
|
|
result = file_service.upload_file(filename=long_name, content=b"test", mimetype="text/plain", user=user)
|
|
assert len(result.name) <= 205 # 200 + . + extension
|
|
assert result.name.endswith(".txt")
|
|
assert db_session.get(UploadFile, result.id) is not None
|
|
|
|
def test_upload_file_blocked_extension(self, file_service, config_overrides: Callable[..., None]):
|
|
config_overrides(inner_UPLOAD_FILE_EXTENSION_BLACKLIST="exe")
|
|
with pytest.raises(BlockedFileExtensionError):
|
|
file_service.upload_file(
|
|
filename="test.exe", content=b"", mimetype="application/octet-stream", user=_account()
|
|
)
|
|
|
|
def test_upload_file_unsupported_type_for_datasets(self, file_service):
|
|
with pytest.raises(UnsupportedFileTypeError):
|
|
file_service.upload_file(
|
|
filename="test.jpg", content=b"", mimetype="image/jpeg", user=_account(), source="datasets"
|
|
)
|
|
|
|
def test_upload_file_too_large(self, file_service, config_overrides: Callable[..., None]):
|
|
# 16MB file for an image with 15MB limit
|
|
content = b"a" * (16 * 1024 * 1024)
|
|
config_overrides(UPLOAD_IMAGE_FILE_SIZE_LIMIT=15)
|
|
with pytest.raises(FileTooLargeError):
|
|
file_service.upload_file(filename="test.jpg", content=content, mimetype="image/jpeg", user=_account())
|
|
|
|
def test_upload_file_end_user(self, file_service: FileService, db_session: Session):
|
|
user = EndUser(
|
|
id="end_user_id",
|
|
)
|
|
|
|
with (
|
|
patch("services.file_service.storage"),
|
|
patch("services.file_service.extract_tenant_id") as mock_tenant,
|
|
patch("services.file_service.file_helpers.get_signed_file_url"),
|
|
):
|
|
mock_tenant.return_value = "tenant"
|
|
result = file_service.upload_file(
|
|
filename="test.txt", content=b"test", mimetype="text/plain", user=user
|
|
)
|
|
assert result.created_by_role == CreatorUserRole.END_USER
|
|
assert db_session.get(UploadFile, result.id) is not None
|
|
|
|
def test_is_file_size_within_limit(self, config_overrides: Callable[..., None]):
|
|
config_overrides(
|
|
UPLOAD_IMAGE_FILE_SIZE_LIMIT=10,
|
|
UPLOAD_VIDEO_FILE_SIZE_LIMIT=20,
|
|
UPLOAD_AUDIO_FILE_SIZE_LIMIT=30,
|
|
UPLOAD_FILE_SIZE_LIMIT=5,
|
|
)
|
|
# Image
|
|
assert FileService.is_file_size_within_limit(extension="jpg", file_size=10 * 1024 * 1024) is True
|
|
assert FileService.is_file_size_within_limit(extension="png", file_size=11 * 1024 * 1024) is False
|
|
|
|
# Video
|
|
assert FileService.is_file_size_within_limit(extension="mp4", file_size=20 * 1024 * 1024) is True
|
|
assert FileService.is_file_size_within_limit(extension="avi", file_size=21 * 1024 * 1024) is False
|
|
|
|
# Audio
|
|
assert FileService.is_file_size_within_limit(extension="mp3", file_size=30 * 1024 * 1024) is True
|
|
assert FileService.is_file_size_within_limit(extension="wav", file_size=31 * 1024 * 1024) is False
|
|
|
|
# Default
|
|
assert FileService.is_file_size_within_limit(extension="txt", file_size=5 * 1024 * 1024) is True
|
|
assert FileService.is_file_size_within_limit(extension="pdf", file_size=6 * 1024 * 1024) is False
|
|
assert (
|
|
FileService.is_file_size_within_limit(extension="txt", file_size=0, default_file_size_limit=0) is True
|
|
)
|
|
assert (
|
|
FileService.is_file_size_within_limit(extension="txt", file_size=1, default_file_size_limit=0) is False
|
|
)
|
|
assert (
|
|
FileService.is_file_size_within_limit(
|
|
extension="pdf",
|
|
file_size=6 * 1024 * 1024,
|
|
default_file_size_limit=7,
|
|
)
|
|
is True
|
|
)
|
|
assert (
|
|
FileService.is_file_size_within_limit(
|
|
extension="pdf",
|
|
file_size=8 * 1024 * 1024,
|
|
default_file_size_limit=7,
|
|
)
|
|
is False
|
|
)
|
|
|
|
# Media-specific limits are not affected by the knowledge document override.
|
|
assert (
|
|
FileService.is_file_size_within_limit(
|
|
extension="jpg",
|
|
file_size=11 * 1024 * 1024,
|
|
default_file_size_limit=100,
|
|
)
|
|
is False
|
|
)
|
|
|
|
def test_file_size_limit(self, config_overrides: Callable[..., None]):
|
|
config_overrides(
|
|
UPLOAD_IMAGE_FILE_SIZE_LIMIT=10,
|
|
UPLOAD_VIDEO_FILE_SIZE_LIMIT=20,
|
|
UPLOAD_AUDIO_FILE_SIZE_LIMIT=30,
|
|
UPLOAD_FILE_SIZE_LIMIT=5,
|
|
)
|
|
|
|
assert FileService.file_size_limit(extension="jpg") == 10 * 1024 * 1024
|
|
assert FileService.file_size_limit(extension="mp4") == 20 * 1024 * 1024
|
|
assert FileService.file_size_limit(extension="mp3") == 30 * 1024 * 1024
|
|
assert FileService.file_size_limit(extension="txt") == 5 * 1024 * 1024
|
|
assert FileService.file_size_limit(extension="txt", default_file_size_limit=None) == 5 * 1024 * 1024
|
|
assert FileService.file_size_limit(extension="txt", default_file_size_limit=0) == 0
|
|
assert FileService.file_size_limit(extension="jpg", default_file_size_limit=0) == 10 * 1024 * 1024
|
|
assert FileService.file_size_limit(extension="txt", default_file_size_limit=7) == 7 * 1024 * 1024
|
|
|
|
def test_get_file_base64_success(self, file_service: FileService, db_session: Session):
|
|
self._persist_upload_file(db_session, key="test_key")
|
|
|
|
with patch("services.file_service.storage") as mock_storage:
|
|
mock_storage.load_once.return_value = b"test content"
|
|
|
|
# Execute
|
|
result = file_service.get_file_base64("file_id")
|
|
|
|
# Assert
|
|
assert result == base64.b64encode(b"test content").decode()
|
|
mock_storage.load_once.assert_called_once_with("test_key")
|
|
|
|
def test_get_file_base64_not_found(self, file_service: FileService):
|
|
with pytest.raises(NotFound, match="File not found"):
|
|
file_service.get_file_base64("non_existent")
|
|
|
|
def test_get_file_presigned_url_success(
|
|
self, file_service: FileService, db_session: Session, config_overrides: Callable[..., None]
|
|
):
|
|
config_overrides(FILES_ACCESS_TIMEOUT=300)
|
|
self._persist_upload_file(
|
|
db_session,
|
|
extension="png",
|
|
mime_type="image/png",
|
|
key="upload_files/tenant_id/icon.png",
|
|
)
|
|
|
|
with (
|
|
patch("services.file_service.storage") as mock_storage,
|
|
):
|
|
mock_storage.generate_presigned_url.return_value = "https://s3.example.com/icon.png?signature=test"
|
|
|
|
result = file_service.get_file_presigned_url(file_id="file_id", tenant_id="tenant_id")
|
|
|
|
assert result == "https://s3.example.com/icon.png?signature=test"
|
|
mock_storage.generate_presigned_url.assert_called_once_with(
|
|
"upload_files/tenant_id/icon.png",
|
|
expires_in=300,
|
|
content_type="image/png",
|
|
)
|
|
|
|
def test_get_file_presigned_url_not_found(self, file_service: FileService):
|
|
with pytest.raises(NotFound, match="File not found"):
|
|
file_service.get_file_presigned_url(file_id="file_id", tenant_id="tenant_id")
|
|
|
|
def test_get_icon_url_uses_direct_storage_url_for_cloud_s3(
|
|
self, file_service: FileService, config_overrides: Callable[..., None]
|
|
):
|
|
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, STORAGE_TYPE=StorageType.S3)
|
|
with patch.object(file_service, "get_file_presigned_url", return_value="direct-url") as get_presigned_url:
|
|
result = file_service.get_icon_url("file_id", "tenant_id")
|
|
|
|
assert result == "direct-url"
|
|
get_presigned_url.assert_called_once_with(file_id="file_id", tenant_id="tenant_id")
|
|
|
|
def test_get_icon_url_maps_missing_cloud_file_to_service_error(
|
|
self, file_service: FileService, config_overrides: Callable[..., None]
|
|
) -> None:
|
|
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, STORAGE_TYPE=StorageType.S3)
|
|
with (
|
|
patch.object(file_service, "get_file_presigned_url", side_effect=NotFound("File not found")),
|
|
pytest.raises(FileNotExistsError, match="File reference not found"),
|
|
):
|
|
file_service.get_icon_url("file_id", "tenant_id")
|
|
|
|
@pytest.mark.parametrize(
|
|
("deployment_edition", "storage_type"),
|
|
[
|
|
(DeploymentEdition.COMMUNITY, StorageType.S3),
|
|
(DeploymentEdition.CLOUD, StorageType.LOCAL),
|
|
],
|
|
)
|
|
def test_get_icon_url_uses_preview_url_outside_cloud_s3(
|
|
self,
|
|
file_service: FileService,
|
|
db_session: Session,
|
|
deployment_edition: DeploymentEdition,
|
|
storage_type: StorageType,
|
|
config_overrides: Callable[..., None],
|
|
):
|
|
self._persist_upload_file(db_session)
|
|
config_overrides(DEPLOYMENT_EDITION=deployment_edition, STORAGE_TYPE=storage_type)
|
|
with patch("services.file_service.file_helpers.get_signed_file_url", return_value="preview-url") as get_url:
|
|
result = file_service.get_icon_url("file_id", "tenant_id")
|
|
|
|
assert result == "preview-url"
|
|
get_url.assert_called_once_with(upload_file_id="file_id")
|
|
|
|
@pytest.mark.parametrize(
|
|
("deployment_edition", "storage_type"),
|
|
[
|
|
(DeploymentEdition.COMMUNITY, StorageType.S3),
|
|
(DeploymentEdition.CLOUD, StorageType.LOCAL),
|
|
],
|
|
)
|
|
def test_get_icon_url_rejects_missing_file_outside_cloud_s3(
|
|
self,
|
|
file_service: FileService,
|
|
deployment_edition: DeploymentEdition,
|
|
storage_type: StorageType,
|
|
config_overrides: Callable[..., None],
|
|
) -> None:
|
|
config_overrides(DEPLOYMENT_EDITION=deployment_edition, STORAGE_TYPE=storage_type)
|
|
|
|
with pytest.raises(FileNotExistsError, match="File reference not found"):
|
|
file_service.get_icon_url("file_id", "tenant_id")
|
|
|
|
def test_get_icon_url_rejects_cross_tenant_file_outside_cloud_s3(
|
|
self,
|
|
file_service: FileService,
|
|
db_session: Session,
|
|
config_overrides: Callable[..., None],
|
|
) -> None:
|
|
self._persist_upload_file(db_session, tenant_id="other_tenant_id")
|
|
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY, STORAGE_TYPE=StorageType.LOCAL)
|
|
|
|
with pytest.raises(FileNotExistsError, match="File reference not found"):
|
|
file_service.get_icon_url("file_id", "tenant_id")
|
|
|
|
def test_upload_text_success(self, file_service: FileService, db_session: Session):
|
|
# Setup
|
|
text = "sample text"
|
|
text_name = "test.txt"
|
|
user_id = "user_id"
|
|
tenant_id = "tenant_id"
|
|
|
|
with patch("services.file_service.storage") as mock_storage:
|
|
# Execute
|
|
result = file_service.upload_text(text, text_name, user_id, tenant_id)
|
|
|
|
# Assert
|
|
assert result.name == text_name
|
|
assert result.size == len(text)
|
|
assert result.tenant_id == tenant_id
|
|
assert result.created_by == user_id
|
|
assert result.used is True
|
|
assert result.extension == "txt"
|
|
mock_storage.save.assert_called_once()
|
|
assert db_session.get(UploadFile, result.id) is not None
|
|
|
|
def test_upload_text_long_name(self, file_service: FileService, db_session: Session):
|
|
long_name = "a" * 210
|
|
with patch("services.file_service.storage"):
|
|
result = file_service.upload_text("text", long_name, "user", "tenant")
|
|
assert len(result.name) == 200
|
|
assert db_session.get(UploadFile, result.id) is not None
|
|
|
|
def test_get_file_preview_success(self, file_service: FileService, db_session: Session):
|
|
self._persist_upload_file(db_session, extension="pdf", mime_type="application/pdf")
|
|
|
|
with patch("services.file_service.ExtractProcessor.load_from_upload_file") as mock_extract:
|
|
mock_extract.return_value = "Extracted text content"
|
|
|
|
# Execute
|
|
result = file_service.get_file_preview("file_id", "tenant_id")
|
|
|
|
# Assert
|
|
assert result == "Extracted text content"
|
|
|
|
def test_get_file_preview_not_found(self, file_service: FileService):
|
|
with pytest.raises(NotFound, match="File not found"):
|
|
file_service.get_file_preview("non_existent", "tenant_id")
|
|
|
|
def test_get_file_preview_unsupported_type(self, file_service: FileService, db_session: Session):
|
|
self._persist_upload_file(db_session, extension="exe", mime_type="application/octet-stream")
|
|
with pytest.raises(UnsupportedFileTypeError):
|
|
file_service.get_file_preview("file_id", "tenant_id")
|
|
|
|
def test_get_file_content_success(self, file_service: FileService, db_session: Session):
|
|
self._persist_upload_file(db_session)
|
|
|
|
with patch("services.file_service.storage") as mock_storage:
|
|
mock_storage.load.return_value = b"hello world"
|
|
result = file_service.get_file_content("file_id")
|
|
assert result == "hello world"
|
|
|
|
def test_get_file_content_not_found(self, file_service: FileService):
|
|
with pytest.raises(NotFound, match="File not found"):
|
|
file_service.get_file_content("file_id")
|
|
|
|
def test_delete_file_success(self, file_service: FileService, db_session: Session):
|
|
self._persist_upload_file(db_session)
|
|
|
|
with patch("services.file_service.storage") as mock_storage:
|
|
file_service.delete_file("file_id")
|
|
mock_storage.delete.assert_called_once_with("key")
|
|
db_session.expire_all()
|
|
assert db_session.get(UploadFile, "file_id") is None
|
|
|
|
def test_delete_file_not_found(self, file_service: FileService):
|
|
file_service.delete_file("file_id")
|
|
# Should return without doing anything
|
|
|
|
def test_get_upload_files_by_ids_empty(self, db_session: Session):
|
|
result = FileService.get_upload_files_by_ids("tenant_id", [], session=db_session)
|
|
assert result == {}
|
|
|
|
def test_get_upload_file_by_id_scopes_to_tenant(self, db_session: Session) -> None:
|
|
upload_file = self._persist_upload_file(db_session)
|
|
|
|
assert FileService.get_upload_file_by_id("tenant_id", "file_id", session=db_session) == upload_file
|
|
assert FileService.get_upload_file_by_id("other_tenant_id", "file_id", session=db_session) is None
|
|
|
|
def test_get_upload_files_by_ids(self, db_session: Session):
|
|
upload_file = self._persist_upload_file(db_session, file_id="550e8400-e29b-41d4-a716-446655440000")
|
|
self._persist_upload_file(
|
|
db_session,
|
|
file_id="550e8400-e29b-41d4-a716-446655440001",
|
|
tenant_id="other-tenant",
|
|
)
|
|
|
|
result = FileService.get_upload_files_by_ids(
|
|
"tenant_id",
|
|
["550e8400-e29b-41d4-a716-446655440000", "550e8400-e29b-41d4-a716-446655440001"],
|
|
session=db_session,
|
|
)
|
|
assert result["550e8400-e29b-41d4-a716-446655440000"] == upload_file
|
|
|
|
def test_sanitize_zip_entry_name(self):
|
|
assert FileService._sanitize_zip_entry_name("path/to/file.txt") == "file.txt"
|
|
assert FileService._sanitize_zip_entry_name("../../../etc/passwd") == "passwd"
|
|
assert FileService._sanitize_zip_entry_name(" ") == "file"
|
|
assert FileService._sanitize_zip_entry_name("a\\b") == "a_b"
|
|
|
|
def test_dedupe_zip_entry_name(self):
|
|
used = {"a.txt"}
|
|
assert FileService._dedupe_zip_entry_name("b.txt", used) == "b.txt"
|
|
assert FileService._dedupe_zip_entry_name("a.txt", used) == "a (1).txt"
|
|
used.add("a (1).txt")
|
|
assert FileService._dedupe_zip_entry_name("a.txt", used) == "a (2).txt"
|
|
|
|
def test_build_upload_files_zip_tempfile(self, db_session: Session):
|
|
upload_file = self._persist_upload_file(db_session)
|
|
|
|
with (
|
|
patch("services.file_service.storage") as mock_storage,
|
|
patch("services.file_service.os.remove") as mock_remove,
|
|
):
|
|
mock_storage.load.return_value = [b"chunk1", b"chunk2"]
|
|
|
|
with FileService.build_upload_files_zip_tempfile(upload_files=[upload_file]) as tmp_path:
|
|
assert os.path.exists(tmp_path)
|
|
|
|
mock_remove.assert_called_once()
|
|
|
|
|
|
_TENANT_ID = "11111111-1111-1111-1111-111111111111"
|
|
_ACTOR_ID = "22222222-2222-2222-2222-222222222222"
|
|
_REMOTE_URL = "https://example.com/report.txt"
|
|
|
|
|
|
@dataclass
|
|
class _UploadHarness:
|
|
files: FileService
|
|
stored: dict[str, bytes]
|
|
active_transactions: set[Connection]
|
|
io_events: list[str]
|
|
database_events: list[str]
|
|
|
|
|
|
class TestFileUploadActorCases:
|
|
@staticmethod
|
|
@pytest.fixture
|
|
def upload_harness(
|
|
sqlite_engine: Engine,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> Iterator[_UploadHarness]:
|
|
harness = _UploadHarness(
|
|
files=FileService(session_factory=sqlite_session_factory),
|
|
stored={},
|
|
active_transactions=set(),
|
|
io_events=[],
|
|
database_events=[],
|
|
)
|
|
|
|
def begin(connection: Connection) -> None:
|
|
harness.active_transactions.add(connection)
|
|
harness.database_events.append("begin")
|
|
|
|
def end(connection: Connection) -> None:
|
|
harness.active_transactions.remove(connection)
|
|
harness.database_events.append("end")
|
|
|
|
def save(key: str, content: bytes) -> None:
|
|
assert not harness.active_transactions
|
|
harness.io_events.append("storage")
|
|
harness.stored[key] = content
|
|
|
|
def signed_url(upload_file_id: str) -> str:
|
|
assert not harness.active_transactions
|
|
harness.io_events.append("sign")
|
|
return f"https://files.example.com/{upload_file_id}?sign=test"
|
|
|
|
event.listen(sqlite_engine, "begin", begin)
|
|
event.listen(sqlite_engine, "commit", end)
|
|
event.listen(sqlite_engine, "rollback", end)
|
|
monkeypatch.setattr(storage, "save", save)
|
|
monkeypatch.setattr(file_helpers, "get_signed_file_url", signed_url)
|
|
try:
|
|
yield harness
|
|
finally:
|
|
event.remove(sqlite_engine, "begin", begin)
|
|
event.remove(sqlite_engine, "commit", end)
|
|
event.remove(sqlite_engine, "rollback", end)
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize("role", [CreatorUserRole.ACCOUNT, CreatorUserRole.END_USER])
|
|
@pytest.mark.parametrize("source_url", ["", _REMOTE_URL])
|
|
def test_actor_upload_preserves_owner_creator_and_source_url_without_extra_queries(
|
|
upload_harness: _UploadHarness,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
role: CreatorUserRole,
|
|
source_url: str,
|
|
) -> None:
|
|
actor = FileUploadActor(id=_ACTOR_ID, creator_role=role)
|
|
content = "文件内容".encode()
|
|
|
|
result = upload_harness.files.upload_file_for_actor(
|
|
actor=actor,
|
|
resource_tenant_id=_TENANT_ID,
|
|
filename="report.TXT",
|
|
content=content,
|
|
mimetype="text/plain",
|
|
source_url=source_url,
|
|
)
|
|
|
|
assert isinstance(result, FileUploadResult)
|
|
assert result.name == "report.TXT"
|
|
assert result.size == len(content)
|
|
assert result.extension == "txt"
|
|
assert result.mime_type == "text/plain"
|
|
assert result.created_by == actor.id
|
|
assert isinstance(result.created_at, datetime)
|
|
assert result.tenant_id == _TENANT_ID
|
|
assert result.source_url == (source_url or f"https://files.example.com/{result.id}?sign=test")
|
|
assert upload_harness.database_events == ["begin", "end"]
|
|
assert not upload_harness.active_transactions
|
|
assert upload_harness.io_events == (["storage"] if source_url else ["storage", "sign"])
|
|
|
|
with sqlite_session_factory() as session:
|
|
persisted = session.get(UploadFile, result.id)
|
|
assert persisted is not None
|
|
assert persisted.created_by_role == role
|
|
assert persisted.created_by == actor.id
|
|
assert persisted.tenant_id == _TENANT_ID
|
|
assert persisted.source_url == source_url
|
|
assert persisted.hash == hashlib.sha3_256(content).hexdigest()
|
|
assert persisted.key.startswith(f"upload_files/{_TENANT_ID}/")
|
|
assert upload_harness.stored == {persisted.key: content}
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize("head_status", [httpx.codes.OK, httpx.codes.METHOD_NOT_ALLOWED])
|
|
def test_remote_actor_upload_fetches_before_transaction_and_retains_original_url(
|
|
upload_harness: _UploadHarness,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
head_status: int,
|
|
) -> None:
|
|
actor = FileUploadActor(id=_ACTOR_ID, creator_role=CreatorUserRole.ACCOUNT)
|
|
content = b"remote file content"
|
|
requests: list[tuple[str, dict[str, object]]] = []
|
|
|
|
def make_request(method: str, *, url: str, **kwargs: object) -> httpx.Response:
|
|
assert not upload_harness.active_transactions
|
|
assert url == _REMOTE_URL
|
|
requests.append((method, kwargs))
|
|
upload_harness.io_events.append(method)
|
|
return httpx.Response(
|
|
head_status if method == "HEAD" else httpx.codes.OK,
|
|
content=content if method == "GET" else b"",
|
|
headers={"Content-Type": "text/plain", "Content-Length": str(len(content))},
|
|
request=httpx.Request(method, url),
|
|
)
|
|
|
|
monkeypatch.setattr(remote_fetcher, "make_request", make_request)
|
|
|
|
result = RemoteFileService(files=upload_harness.files).upload_from_url(
|
|
url=_REMOTE_URL, user=actor, tenant_id=_TENANT_ID
|
|
)
|
|
|
|
assert result.name == "report.txt"
|
|
assert result.size == len(content)
|
|
assert result.extension == "txt"
|
|
assert result.mime_type == "text/plain"
|
|
assert result.created_by == actor.id
|
|
assert result.url == f"https://files.example.com/{result.id}?sign=test"
|
|
assert requests == [
|
|
("HEAD", {}),
|
|
("GET", {} if head_status == httpx.codes.OK else {"timeout": 3, "follow_redirects": True}),
|
|
]
|
|
assert upload_harness.io_events == ["HEAD", "GET", "storage", "sign"]
|
|
assert upload_harness.database_events == ["begin", "end"]
|
|
assert not upload_harness.active_transactions
|
|
|
|
with sqlite_session_factory() as session:
|
|
persisted = session.scalars(select(UploadFile)).one()
|
|
assert persisted.id == result.id
|
|
assert persisted.created_by == actor.id
|
|
assert persisted.created_by_role == CreatorUserRole.ACCOUNT
|
|
assert persisted.tenant_id == _TENANT_ID
|
|
assert persisted.source_url == _REMOTE_URL
|
|
assert persisted.key.startswith(f"upload_files/{_TENANT_ID}/")
|
|
assert upload_harness.stored == {persisted.key: content}
|
|
|
|
@staticmethod
|
|
def test_same_actor_can_upload_to_separately_admitted_tenants(
|
|
upload_harness: _UploadHarness,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
actor = FileUploadActor(id=_ACTOR_ID, creator_role=CreatorUserRole.ACCOUNT)
|
|
tenant_ids = (_TENANT_ID, "33333333-3333-3333-3333-333333333333")
|
|
|
|
results = [
|
|
upload_harness.files.upload_file_for_actor(
|
|
actor=actor,
|
|
resource_tenant_id=tenant_id,
|
|
filename="report.txt",
|
|
content=tenant_id.encode(),
|
|
mimetype="text/plain",
|
|
)
|
|
for tenant_id in tenant_ids
|
|
]
|
|
|
|
assert [result.tenant_id for result in results] == list(tenant_ids)
|
|
assert len({result.id for result in results}) == 2
|
|
with sqlite_session_factory() as session:
|
|
for result, tenant_id in zip(results, tenant_ids, strict=True):
|
|
persisted = session.get(UploadFile, result.id)
|
|
assert persisted is not None
|
|
assert persisted.tenant_id == tenant_id
|
|
assert persisted.created_by == actor.id
|
|
assert persisted.created_by_role == actor.creator_role
|
|
assert persisted.key.startswith(f"upload_files/{tenant_id}/")
|
|
assert upload_harness.stored[persisted.key] == tenant_id.encode()
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize("remote", [False, True], ids=["local", "remote"])
|
|
def test_actor_upload_without_target_tenant_fails_before_external_io(
|
|
upload_harness: _UploadHarness,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
remote: bool,
|
|
) -> None:
|
|
actor = FileUploadActor(id=_ACTOR_ID, creator_role=CreatorUserRole.ACCOUNT)
|
|
|
|
def unexpected_request(*_args: object, **_kwargs: object) -> httpx.Response:
|
|
pytest.fail("Missing target tenant must be rejected before fetching the remote file")
|
|
|
|
monkeypatch.setattr(remote_fetcher, "make_request", unexpected_request)
|
|
|
|
if remote:
|
|
with pytest.raises(TypeError, match="tenant_id"):
|
|
RemoteFileService(files=upload_harness.files).upload_from_url(url=_REMOTE_URL, user=actor)
|
|
else:
|
|
with pytest.raises(TypeError, match="tenant_id"):
|
|
upload_harness.files.upload_file(
|
|
filename="report.txt", content=b"file content", mimetype="text/plain", user=actor
|
|
)
|
|
|
|
assert upload_harness.io_events == []
|
|
assert upload_harness.database_events == []
|
|
assert upload_harness.stored == {}
|