feat: integrate MinIO-backed user filesystem
This commit is contained in:
153
app-instance/backend/tests/unit/test_user_file_service.py
Normal file
153
app-instance/backend/tests/unit/test_user_file_service.py
Normal file
@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
|
||||
from beaver.services.user_files import (
|
||||
LocalUserFileStorage,
|
||||
MinIOStorageConfig,
|
||||
MinIOUserFileStorage,
|
||||
UserFileNotFoundError,
|
||||
UserFilePathError,
|
||||
UserFileSizeError,
|
||||
UserFileService,
|
||||
normalize_user_path,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_user_path_accepts_fixed_roots() -> None:
|
||||
assert normalize_user_path("uploads/readme.txt", allow_root=False) == "uploads/readme.txt"
|
||||
assert normalize_user_path("outputs/report.md", allow_root=False) == "outputs/report.md"
|
||||
assert normalize_user_path("tasks/task-123/draft.md", allow_root=False) == "tasks/task-123/draft.md"
|
||||
assert normalize_user_path("", allow_root=True) == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
"../secret.txt",
|
||||
"/uploads/input.txt",
|
||||
"/outputs/result.txt",
|
||||
"/shared/profile.json",
|
||||
"/tasks/task-123/draft.md",
|
||||
"uploads/../state/config.json",
|
||||
"memory/private.txt",
|
||||
"uploads/.internal",
|
||||
"",
|
||||
],
|
||||
)
|
||||
def test_normalize_user_path_rejects_invalid_paths(path: str) -> None:
|
||||
with pytest.raises(UserFilePathError):
|
||||
normalize_user_path(path, allow_root=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_file_service_root_and_round_trip(tmp_path) -> None:
|
||||
service = UserFileService(LocalUserFileStorage(tmp_path / "user-files"))
|
||||
|
||||
root = await service.browse("")
|
||||
uploaded = await service.upload(
|
||||
"uploads",
|
||||
"hello.txt",
|
||||
b"hello user files",
|
||||
content_type="text/plain",
|
||||
)
|
||||
uploads = await service.browse("uploads")
|
||||
preview = await service.preview("uploads/hello.txt")
|
||||
downloaded = await service.download("uploads/hello.txt")
|
||||
deleted = await service.delete("uploads/hello.txt")
|
||||
|
||||
assert [item["name"] for item in root["items"]] == ["uploads", "outputs", "shared", "tasks"]
|
||||
assert uploaded["path"] == "uploads/hello.txt"
|
||||
assert uploaded["content_type"] == "text/plain"
|
||||
assert [item["name"] for item in uploads["items"]] == ["hello.txt"]
|
||||
assert preview["content"] == "hello user files"
|
||||
assert downloaded.content == b"hello user files"
|
||||
assert deleted is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_file_service_stream_upload_and_size_limit(tmp_path) -> None:
|
||||
service = UserFileService(LocalUserFileStorage(tmp_path / "user-files"))
|
||||
|
||||
uploaded = await service.upload_stream(
|
||||
"uploads",
|
||||
"streamed.txt",
|
||||
BytesIO(b"streamed user file"),
|
||||
content_type="text/plain",
|
||||
max_bytes=1024,
|
||||
part_size=4,
|
||||
)
|
||||
preview = await service.preview("uploads/streamed.txt")
|
||||
|
||||
assert uploaded["path"] == "uploads/streamed.txt"
|
||||
assert uploaded["size"] == len(b"streamed user file")
|
||||
assert preview["content"] == "streamed user file"
|
||||
|
||||
with pytest.raises(UserFileSizeError):
|
||||
await service.upload_stream(
|
||||
"uploads",
|
||||
"too-large.txt",
|
||||
BytesIO(b"abcdef"),
|
||||
content_type="text/plain",
|
||||
max_bytes=5,
|
||||
part_size=2,
|
||||
)
|
||||
with pytest.raises(UserFileNotFoundError):
|
||||
await service.preview("uploads/too-large.txt")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_file_service_rejects_root_delete_and_traversal(tmp_path) -> None:
|
||||
service = UserFileService(LocalUserFileStorage(tmp_path / "user-files"))
|
||||
|
||||
with pytest.raises(UserFilePathError):
|
||||
await service.delete("uploads")
|
||||
|
||||
with pytest.raises(UserFilePathError):
|
||||
await service.upload("../workspace", "hello.txt", b"x", content_type="text/plain")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_file_service_creates_nested_directories(tmp_path) -> None:
|
||||
service = UserFileService(LocalUserFileStorage(tmp_path / "user-files"))
|
||||
|
||||
created = await service.mkdir("tasks/task-123/references")
|
||||
tasks = await service.browse("tasks/task-123")
|
||||
|
||||
assert created["path"] == "tasks/task-123/references"
|
||||
assert created["type"] == "directory"
|
||||
assert [item["name"] for item in tasks["items"]] == ["references"]
|
||||
|
||||
|
||||
def test_minio_storage_maps_virtual_paths_under_namespace() -> None:
|
||||
storage = object.__new__(MinIOUserFileStorage)
|
||||
storage.config = MinIOStorageConfig(
|
||||
endpoint="minio.local:9000",
|
||||
access_key="alice-access",
|
||||
secret_key="alice-secret",
|
||||
bucket="beaver-user-files",
|
||||
namespace="users/alice",
|
||||
)
|
||||
|
||||
assert storage._object_name("uploads/report.pdf") == "users/alice/uploads/report.pdf"
|
||||
assert storage._object_name("tasks/task-123/result.json") == "users/alice/tasks/task-123/result.json"
|
||||
assert storage._user_path("users/alice/outputs/summary.md") == "outputs/summary.md"
|
||||
|
||||
|
||||
def test_minio_storage_rejects_paths_that_escape_namespace() -> None:
|
||||
storage = object.__new__(MinIOUserFileStorage)
|
||||
storage.config = MinIOStorageConfig(
|
||||
endpoint="minio.local:9000",
|
||||
access_key="alice-access",
|
||||
secret_key="alice-secret",
|
||||
bucket="beaver-user-files",
|
||||
namespace="users/alice",
|
||||
)
|
||||
|
||||
with pytest.raises(UserFilePathError):
|
||||
storage._object_name("uploads/../state/config.json")
|
||||
|
||||
with pytest.raises(UserFilePathError):
|
||||
storage._user_path("users/bob/uploads/secret.txt")
|
||||
Reference in New Issue
Block a user