mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-01 11:47:46 +02:00
Host directories (NAS, USB, network shares) can now be mounted
into the File Manager without copying files. Files are indexed
into the database on scan but read directly from their original
location. Supports read-only mode, hidden file filtering, and
automatic thumbnail extraction for 3MF/STL/gcode.
- POST /library/folders/external — create with path validation
- POST /library/folders/{id}/scan — discover/sync files
- Block uploads, moves, and deletes for read-only external folders
- Never delete actual files from external paths (DB-only removal)
- Purple folder icon + info bar with rescan button in UI
- i18n for all 7 languages
- 19 backend + 11 frontend tests
379 lines
15 KiB
Python
379 lines
15 KiB
Python
"""Integration tests for External Folder API endpoints."""
|
|
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
|
|
class TestExternalFolderCreation:
|
|
"""Tests for POST /library/folders/external."""
|
|
|
|
@pytest.fixture
|
|
def external_dir(self, tmp_path):
|
|
"""Create a temporary directory to act as an external folder."""
|
|
ext_dir = tmp_path / "nas_share"
|
|
ext_dir.mkdir()
|
|
# Add some test files
|
|
(ext_dir / "benchy.3mf").write_bytes(b"fake3mf")
|
|
(ext_dir / "bracket.stl").write_bytes(b"fakestl")
|
|
(ext_dir / "print.gcode").write_text("G28\nG1 X10 Y10")
|
|
(ext_dir / "readme.txt").write_text("not a print file")
|
|
(ext_dir / ".hidden.3mf").write_bytes(b"hidden")
|
|
return ext_dir
|
|
|
|
@pytest.fixture
|
|
def nested_external_dir(self, external_dir):
|
|
"""Create a nested subdirectory in the external folder."""
|
|
sub = external_dir / "subfolder"
|
|
sub.mkdir()
|
|
(sub / "nested_part.stl").write_bytes(b"nestedstl")
|
|
return external_dir
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_create_external_folder(self, async_client: AsyncClient, db_session, external_dir):
|
|
"""Verify external folder can be created with valid path."""
|
|
data = {
|
|
"name": "NAS Prints",
|
|
"external_path": str(external_dir),
|
|
"readonly": True,
|
|
"show_hidden": False,
|
|
}
|
|
response = await async_client.post("/api/v1/library/folders/external", json=data)
|
|
assert response.status_code == 200
|
|
result = response.json()
|
|
assert result["name"] == "NAS Prints"
|
|
assert result["is_external"] is True
|
|
assert result["external_readonly"] is True
|
|
assert result["external_show_hidden"] is False
|
|
assert result["external_path"] == str(external_dir.resolve())
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_create_external_folder_nonexistent_path(self, async_client: AsyncClient, db_session):
|
|
"""Verify 400 for non-existent path."""
|
|
data = {
|
|
"name": "Bad Path",
|
|
"external_path": "/nonexistent/path/that/does/not/exist",
|
|
}
|
|
response = await async_client.post("/api/v1/library/folders/external", json=data)
|
|
assert response.status_code == 400
|
|
assert "does not exist" in response.json()["detail"]
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_create_external_folder_system_dir_blocked(self, async_client: AsyncClient, db_session):
|
|
"""Verify system directories are blocked."""
|
|
data = {
|
|
"name": "System",
|
|
"external_path": "/proc",
|
|
}
|
|
response = await async_client.post("/api/v1/library/folders/external", json=data)
|
|
assert response.status_code == 400
|
|
assert "system directory" in response.json()["detail"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_create_external_folder_file_not_dir(self, async_client: AsyncClient, db_session, tmp_path):
|
|
"""Verify 400 when path is a file, not directory."""
|
|
file_path = tmp_path / "not_a_dir.txt"
|
|
file_path.write_text("hello")
|
|
data = {
|
|
"name": "Not A Dir",
|
|
"external_path": str(file_path),
|
|
}
|
|
response = await async_client.post("/api/v1/library/folders/external", json=data)
|
|
assert response.status_code == 400
|
|
assert "not a directory" in response.json()["detail"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_create_external_folder_duplicate_path(self, async_client: AsyncClient, db_session, external_dir):
|
|
"""Verify 409 when same path already linked."""
|
|
data = {
|
|
"name": "First",
|
|
"external_path": str(external_dir),
|
|
}
|
|
response = await async_client.post("/api/v1/library/folders/external", json=data)
|
|
assert response.status_code == 200
|
|
|
|
data["name"] = "Duplicate"
|
|
response = await async_client.post("/api/v1/library/folders/external", json=data)
|
|
assert response.status_code == 409
|
|
assert "already exists" in response.json()["detail"]
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_external_folder_appears_in_tree(self, async_client: AsyncClient, db_session, external_dir):
|
|
"""Verify external folder shows up in folder tree with external fields."""
|
|
data = {
|
|
"name": "My NAS",
|
|
"external_path": str(external_dir),
|
|
"readonly": True,
|
|
}
|
|
await async_client.post("/api/v1/library/folders/external", json=data)
|
|
|
|
response = await async_client.get("/api/v1/library/folders")
|
|
assert response.status_code == 200
|
|
folders = response.json()
|
|
ext_folder = next((f for f in folders if f["name"] == "My NAS"), None)
|
|
assert ext_folder is not None
|
|
assert ext_folder["is_external"] is True
|
|
assert ext_folder["external_readonly"] is True
|
|
|
|
|
|
class TestExternalFolderScan:
|
|
"""Tests for POST /library/folders/{id}/scan."""
|
|
|
|
@pytest.fixture
|
|
def external_dir(self, tmp_path):
|
|
"""Create a temporary directory with test files."""
|
|
ext_dir = tmp_path / "prints"
|
|
ext_dir.mkdir()
|
|
(ext_dir / "benchy.3mf").write_bytes(b"fake3mf")
|
|
(ext_dir / "bracket.stl").write_bytes(b"fakestl")
|
|
(ext_dir / "print.gcode").write_text("G28\nG1 X10 Y10")
|
|
(ext_dir / "readme.txt").write_text("not a print file")
|
|
(ext_dir / ".hidden.3mf").write_bytes(b"hidden")
|
|
sub = ext_dir / "subfolder"
|
|
sub.mkdir()
|
|
(sub / "nested.stl").write_bytes(b"nested")
|
|
return ext_dir
|
|
|
|
@pytest.fixture
|
|
async def external_folder(self, async_client, db_session, external_dir):
|
|
"""Create an external folder via API."""
|
|
data = {
|
|
"name": "Scan Test",
|
|
"external_path": str(external_dir),
|
|
"readonly": True,
|
|
"show_hidden": False,
|
|
}
|
|
response = await async_client.post("/api/v1/library/folders/external", json=data)
|
|
return response.json()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_scan_discovers_files(self, async_client: AsyncClient, db_session, external_folder):
|
|
"""Verify scan discovers supported files."""
|
|
response = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
|
|
assert response.status_code == 200
|
|
result = response.json()
|
|
# Should find: benchy.3mf, bracket.stl, print.gcode, subfolder/nested.stl
|
|
# Should skip: readme.txt (unsupported), .hidden.3mf (hidden)
|
|
assert result["added"] == 4
|
|
assert result["removed"] == 0
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_scan_skips_hidden_files(self, async_client: AsyncClient, db_session, external_folder):
|
|
"""Verify hidden files are skipped by default."""
|
|
await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
|
|
|
|
# List files in folder
|
|
response = await async_client.get(f"/api/v1/library/files?folder_id={external_folder['id']}")
|
|
assert response.status_code == 200
|
|
files = response.json()
|
|
filenames = [f["filename"] for f in files]
|
|
assert ".hidden.3mf" not in filenames
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_scan_shows_hidden_when_enabled(self, async_client: AsyncClient, db_session, external_dir):
|
|
"""Verify hidden files found when show_hidden=True."""
|
|
data = {
|
|
"name": "Show Hidden Test",
|
|
"external_path": str(external_dir),
|
|
"show_hidden": True,
|
|
}
|
|
response = await async_client.post("/api/v1/library/folders/external", json=data)
|
|
folder = response.json()
|
|
|
|
response = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
|
|
result = response.json()
|
|
# Now should also find .hidden.3mf → 5 total
|
|
assert result["added"] == 5
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_scan_idempotent(self, async_client: AsyncClient, db_session, external_folder):
|
|
"""Verify scanning twice doesn't duplicate files."""
|
|
response1 = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
|
|
assert response1.json()["added"] == 4
|
|
|
|
response2 = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
|
|
assert response2.json()["added"] == 0
|
|
assert response2.json()["removed"] == 0
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_scan_removes_deleted_files(
|
|
self, async_client: AsyncClient, db_session, external_folder, external_dir
|
|
):
|
|
"""Verify scan removes entries for files no longer on disk."""
|
|
await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
|
|
|
|
# Delete a file from disk
|
|
(external_dir / "bracket.stl").unlink()
|
|
|
|
response = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
|
|
result = response.json()
|
|
assert result["removed"] == 1
|
|
assert result["added"] == 0
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_scan_non_external_folder_fails(self, async_client: AsyncClient, db_session):
|
|
"""Verify scan fails on regular (non-external) folder."""
|
|
# Create a regular folder
|
|
data = {"name": "Regular Folder"}
|
|
response = await async_client.post("/api/v1/library/folders", json=data)
|
|
folder = response.json()
|
|
|
|
response = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
|
|
assert response.status_code == 400
|
|
assert "not an external" in response.json()["detail"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_scan_files_marked_external(self, async_client: AsyncClient, db_session, external_folder):
|
|
"""Verify scanned files have is_external=True."""
|
|
await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
|
|
|
|
response = await async_client.get(f"/api/v1/library/files?folder_id={external_folder['id']}")
|
|
files = response.json()
|
|
assert len(files) > 0
|
|
for f in files:
|
|
assert f["is_external"] is True
|
|
|
|
|
|
class TestExternalFolderProtections:
|
|
"""Tests for read-only protections on external folders."""
|
|
|
|
@pytest.fixture
|
|
def external_dir(self, tmp_path):
|
|
ext_dir = tmp_path / "readonly_share"
|
|
ext_dir.mkdir()
|
|
(ext_dir / "test.stl").write_bytes(b"fakestl")
|
|
return ext_dir
|
|
|
|
@pytest.fixture
|
|
async def readonly_folder(self, async_client, db_session, external_dir):
|
|
"""Create a read-only external folder with files scanned."""
|
|
data = {
|
|
"name": "Read Only",
|
|
"external_path": str(external_dir),
|
|
"readonly": True,
|
|
}
|
|
response = await async_client.post("/api/v1/library/folders/external", json=data)
|
|
folder = response.json()
|
|
await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
|
|
return folder
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_upload_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
|
|
"""Verify uploads to read-only external folders are blocked."""
|
|
import io
|
|
|
|
file_content = io.BytesIO(b"test content")
|
|
response = await async_client.post(
|
|
f"/api/v1/library/files?folder_id={readonly_folder['id']}",
|
|
files={"file": ("test.gcode", file_content, "application/octet-stream")},
|
|
)
|
|
assert response.status_code == 403
|
|
assert "read-only" in response.json()["detail"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_move_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
|
|
"""Verify moving files to read-only external folder is blocked."""
|
|
from backend.app.models.library import LibraryFile
|
|
|
|
# Create a regular file
|
|
lib_file = LibraryFile(
|
|
filename="regular.3mf",
|
|
file_path="/test/regular.3mf",
|
|
file_size=1024,
|
|
file_type="3mf",
|
|
)
|
|
db_session.add(lib_file)
|
|
await db_session.commit()
|
|
await db_session.refresh(lib_file)
|
|
|
|
data = {"file_ids": [lib_file.id], "folder_id": readonly_folder["id"]}
|
|
response = await async_client.post("/api/v1/library/files/move", json=data)
|
|
assert response.status_code == 403
|
|
assert "read-only" in response.json()["detail"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_external_files_cannot_be_moved_out(self, async_client: AsyncClient, db_session, readonly_folder):
|
|
"""Verify external files can't be moved to other folders."""
|
|
# Get the external file ID
|
|
response = await async_client.get(f"/api/v1/library/files?folder_id={readonly_folder['id']}")
|
|
files = response.json()
|
|
assert len(files) > 0
|
|
ext_file_id = files[0]["id"]
|
|
|
|
# Try to move to root
|
|
data = {"file_ids": [ext_file_id], "folder_id": None}
|
|
response = await async_client.post("/api/v1/library/files/move", json=data)
|
|
assert response.status_code == 200
|
|
# File should be skipped, not moved
|
|
result = response.json()
|
|
assert result["moved"] == 0
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_delete_external_file_removes_db_only(
|
|
self, async_client: AsyncClient, db_session, readonly_folder, external_dir
|
|
):
|
|
"""Verify deleting an external file only removes DB entry, not the file on disk."""
|
|
response = await async_client.get(f"/api/v1/library/files?folder_id={readonly_folder['id']}")
|
|
files = response.json()
|
|
ext_file_id = files[0]["id"]
|
|
ext_filename = files[0]["filename"]
|
|
|
|
# Delete via API
|
|
response = await async_client.delete(f"/api/v1/library/files/{ext_file_id}")
|
|
assert response.status_code == 200
|
|
|
|
# File should still exist on disk
|
|
assert (external_dir / ext_filename).exists()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_delete_external_folder_preserves_files(
|
|
self, async_client: AsyncClient, db_session, readonly_folder, external_dir
|
|
):
|
|
"""Verify deleting an external folder doesn't delete files from disk."""
|
|
response = await async_client.delete(f"/api/v1/library/folders/{readonly_folder['id']}")
|
|
assert response.status_code == 200
|
|
|
|
# Files should still exist on disk
|
|
assert (external_dir / "test.stl").exists()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_zip_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
|
|
"""Verify ZIP extraction to read-only external folder is blocked."""
|
|
import io
|
|
import zipfile
|
|
|
|
# Create a minimal zip
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w") as zf:
|
|
zf.writestr("test.stl", b"fakestl")
|
|
buf.seek(0)
|
|
|
|
response = await async_client.post(
|
|
f"/api/v1/library/files/extract-zip?folder_id={readonly_folder['id']}",
|
|
files={"file": ("test.zip", buf, "application/zip")},
|
|
)
|
|
assert response.status_code == 403
|
|
assert "read-only" in response.json()["detail"].lower()
|