File Manager: external link, notes and photos on library files (#3128)

This commit is contained in:
Thomansky
2026-09-26 08:56:48 +02:00
committed by GitHub
parent 222f28c7f8
commit 12dddada0a
49 changed files with 4817 additions and 57 deletions
@@ -0,0 +1,436 @@
"""Integration tests for library file notes, external link and photos (#3077).
Pins the contracts of the details modal's backend: the PUT round-trip for
``external_url`` (empty string clears), the list-view indicators
(``has_notes`` / ``photo_count``), and the photo routes — membership check
before any disk access, extension allowlist, size cap, and the photo
directory going away with the file.
"""
import io
import pytest
from httpx import AsyncClient
from PIL import Image
from backend.app.core.config import settings as app_settings
from backend.app.models.library import LibraryFile
from backend.app.models.user import User
from backend.app.utils.library_paths import library_photos_dir
def _jpeg_bytes() -> bytes:
buf = io.BytesIO()
Image.new("RGB", (32, 32), "red").save(buf, "JPEG")
return buf.getvalue()
@pytest.fixture
def isolated_storage(monkeypatch, tmp_path):
"""Point library storage at a throwaway directory."""
monkeypatch.setattr(app_settings, "base_dir", tmp_path)
monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
return tmp_path
@pytest.fixture
async def file_factory(db_session):
"""Factory for LibraryFile rows with sensible defaults."""
_counter = [0]
async def _create_file(**kwargs):
_counter[0] += 1
counter = _counter[0]
defaults = {
"filename": f"part{counter}.3mf",
"file_path": f"library/files/part{counter}.3mf",
"file_type": "3mf",
"file_size": 100,
}
defaults.update(kwargs)
library_file = LibraryFile(**defaults)
db_session.add(library_file)
await db_session.commit()
await db_session.refresh(library_file)
return library_file
return _create_file
async def _upload(async_client: AsyncClient, file_id: int, name: str = "result.jpg", content: bytes | None = None):
return await async_client.post(
f"/api/v1/library/files/{file_id}/photos",
files={"file": (name, content if content is not None else _jpeg_bytes(), "image/jpeg")},
)
class TestExternalUrlAndNotes:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_external_url_round_trip(self, async_client: AsyncClient, file_factory, isolated_storage):
library_file = await file_factory()
response = await async_client.put(
f"/api/v1/library/files/{library_file.id}",
json={"external_url": "https://www.printables.com/model/1234", "notes": "Print at 0.2mm"},
)
assert response.status_code == 200
body = response.json()
assert body["external_url"] == "https://www.printables.com/model/1234"
assert body["notes"] == "Print at 0.2mm"
assert body["photos"] == []
detail = await async_client.get(f"/api/v1/library/files/{library_file.id}")
assert detail.json()["external_url"] == "https://www.printables.com/model/1234"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_empty_string_clears_external_url(self, async_client: AsyncClient, file_factory, isolated_storage):
library_file = await file_factory(external_url="https://example.com/x")
response = await async_client.put(f"/api/v1/library/files/{library_file.id}", json={"external_url": ""})
assert response.status_code == 200
assert response.json()["external_url"] is None
@pytest.mark.asyncio
@pytest.mark.integration
@pytest.mark.parametrize(
"url",
["javascript:alert(1)", "data:text/html,hi", "ftp://example.com/x", "www.printables.com/model/1"],
)
async def test_non_http_external_url_is_rejected(
self, async_client: AsyncClient, file_factory, isolated_storage, url: str
):
library_file = await file_factory(external_url="https://example.com/x")
response = await async_client.put(f"/api/v1/library/files/{library_file.id}", json={"external_url": url})
assert response.status_code == 422
detail = await async_client.get(f"/api/v1/library/files/{library_file.id}")
assert detail.json()["external_url"] == "https://example.com/x"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_omitted_external_url_is_left_alone(self, async_client: AsyncClient, file_factory, isolated_storage):
library_file = await file_factory(external_url="https://example.com/x")
response = await async_client.put(f"/api/v1/library/files/{library_file.id}", json={"notes": "hi"})
assert response.status_code == 200
assert response.json()["external_url"] == "https://example.com/x"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_detail_exposes_source_url_read_only(self, async_client: AsyncClient, file_factory, isolated_storage):
library_file = await file_factory(source_type="makerworld", source_url="https://makerworld.com/models/1")
detail = await async_client.get(f"/api/v1/library/files/{library_file.id}")
assert detail.json()["source_url"] == "https://makerworld.com/models/1"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_carries_indicators_but_not_notes(
self, async_client: AsyncClient, file_factory, isolated_storage
):
with_meta = await file_factory(
notes="secret notes", external_url="https://example.com/a", photos=["a.jpg", "b.png"]
)
bare = await file_factory()
response = await async_client.get("/api/v1/library/files")
assert response.status_code == 200
by_id = {item["id"]: item for item in response.json()}
assert by_id[with_meta.id]["has_notes"] is True
assert by_id[with_meta.id]["photo_count"] == 2
assert by_id[with_meta.id]["external_url"] == "https://example.com/a"
assert "notes" not in by_id[with_meta.id]
assert "photos" not in by_id[with_meta.id]
assert by_id[bare.id]["has_notes"] is False
assert by_id[bare.id]["photo_count"] == 0
assert by_id[bare.id]["external_url"] is None
class TestPhotos:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_serve_delete_round_trip(
self, async_client: AsyncClient, db_session, file_factory, isolated_storage
):
library_file = await file_factory()
upload = await _upload(async_client, library_file.id)
assert upload.status_code == 200
body = upload.json()
filename = body["filename"]
assert body["status"] == "uploaded"
assert body["photos"] == [filename]
assert filename.endswith(".jpg")
assert (library_photos_dir(library_file.id) / filename).is_file()
await db_session.refresh(library_file)
assert library_file.photos == [filename]
served = await async_client.get(f"/api/v1/library/files/{library_file.id}/photos/{filename}")
assert served.status_code == 200
assert served.headers["content-type"] == "image/jpeg"
assert served.content == _jpeg_bytes()
detail = await async_client.get(f"/api/v1/library/files/{library_file.id}")
assert detail.json()["photos"] == [filename]
deleted = await async_client.delete(f"/api/v1/library/files/{library_file.id}/photos/{filename}")
assert deleted.status_code == 200
assert deleted.json() == {"status": "deleted", "photos": []}
assert not (library_photos_dir(library_file.id) / filename).exists()
gone = await async_client.get(f"/api/v1/library/files/{library_file.id}/photos/{filename}")
assert gone.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_external_file_takes_photos_too(self, async_client: AsyncClient, file_factory, isolated_storage):
library_file = await file_factory(is_external=True, file_path="/mnt/nas/part.stl", file_type="stl")
upload = await _upload(async_client, library_file.id, name="shot.png")
assert upload.status_code == 200
assert (library_photos_dir(library_file.id) / upload.json()["filename"]).is_file()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_unlisted_filename_is_404_even_when_on_disk(
self, async_client: AsyncClient, file_factory, isolated_storage
):
library_file = await file_factory()
photos_dir = library_photos_dir(library_file.id)
photos_dir.mkdir(parents=True)
(photos_dir / "stray.jpg").write_bytes(_jpeg_bytes())
response = await async_client.get(f"/api/v1/library/files/{library_file.id}/photos/stray.jpg")
assert response.status_code == 404
response = await async_client.delete(f"/api/v1/library/files/{library_file.id}/photos/stray.jpg")
assert response.status_code == 404
assert (photos_dir / "stray.jpg").exists()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_traversal_filename_is_rejected(self, async_client: AsyncClient, file_factory, isolated_storage):
# Even a traversal-looking name that IS in the stored list never leaves
# the photo directory — the membership check is not the only guard.
library_file = await file_factory(photos=["../../secret.jpg"])
(isolated_storage / "archive" / "secret.jpg").parent.mkdir(parents=True, exist_ok=True)
(isolated_storage / "archive" / "secret.jpg").write_bytes(_jpeg_bytes())
response = await async_client.get(
f"/api/v1/library/files/{library_file.id}/photos/..%2F..%2Fsecret.jpg",
)
assert response.status_code in (400, 404)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_wrong_extension_is_rejected(self, async_client: AsyncClient, file_factory, isolated_storage):
library_file = await file_factory()
response = await _upload(async_client, library_file.id, name="notes.txt", content=b"hello")
assert response.status_code == 400
assert not library_photos_dir(library_file.id).exists()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_oversized_upload_is_rejected(self, async_client: AsyncClient, file_factory, isolated_storage):
library_file = await file_factory()
response = await _upload(async_client, library_file.id, content=b"\xff" * (10 * 1024 * 1024 + 1))
assert response.status_code == 413
assert not library_photos_dir(library_file.id).exists()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_to_missing_file_is_404(self, async_client: AsyncClient, isolated_storage):
response = await _upload(async_client, 999999)
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_trash_purge_removes_photo_dir(
self, async_client: AsyncClient, db_session, file_factory, isolated_storage
):
library_file = await file_factory()
upload = await _upload(async_client, library_file.id)
assert upload.status_code == 200
photos_dir = library_photos_dir(library_file.id)
assert photos_dir.is_dir()
trashed = await async_client.delete(f"/api/v1/library/files/{library_file.id}")
assert trashed.status_code == 200
# Soft-delete keeps the photos, like the file bytes and thumbnail.
assert photos_dir.is_dir()
purged = await async_client.delete(f"/api/v1/library/trash/{library_file.id}")
assert purged.status_code == 200
assert not photos_dir.exists()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_external_file_delete_removes_photo_dir(
self, async_client: AsyncClient, file_factory, isolated_storage
):
library_file = await file_factory(is_external=True, file_path="/mnt/nas/part.stl", file_type="stl")
upload = await _upload(async_client, library_file.id)
assert upload.status_code == 200
photos_dir = library_photos_dir(library_file.id)
response = await async_client.delete(f"/api/v1/library/files/{library_file.id}")
assert response.status_code == 200
assert response.json()["trashed"] is False
assert not photos_dir.exists()
class TestPhotoDirectoryCleanup:
"""Every path that hard-deletes a library row takes its photos with it.
The upload/delete round-trip, the trash purge and the external single-file
delete are covered above; these are the remaining ones — folder delete,
bulk delete of files and of whole folders, the external-folder scan that
drops rows for files that vanished from the share, and the admin user
delete that takes the user's items with them.
"""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_folder_delete_removes_photo_dir(self, async_client: AsyncClient, file_factory, isolated_storage):
folder = await async_client.post("/api/v1/library/folders", json={"name": "Brackets"})
assert folder.status_code == 200
folder_id = folder.json()["id"]
library_file = await file_factory(folder_id=folder_id)
assert (await _upload(async_client, library_file.id)).status_code == 200
photos_dir = library_photos_dir(library_file.id)
assert photos_dir.is_dir()
response = await async_client.delete(f"/api/v1/library/folders/{folder_id}")
assert response.status_code == 200
assert not photos_dir.exists()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bulk_delete_removes_photo_dir_of_hard_deleted_file(
self, async_client: AsyncClient, file_factory, isolated_storage
):
# External files bypass the trash, so bulk delete hard-deletes them;
# a managed file is only soft-deleted and keeps its photos until the
# sweeper runs.
external = await file_factory(is_external=True, file_path="/mnt/nas/ext.stl", file_type="stl")
managed = await file_factory()
for library_file in (external, managed):
assert (await _upload(async_client, library_file.id)).status_code == 200
response = await async_client.post(
"/api/v1/library/bulk-delete",
json={"file_ids": [external.id, managed.id], "folder_ids": []},
)
assert response.status_code == 200
assert response.json()["deleted_files"] == 2
assert not library_photos_dir(external.id).exists()
assert library_photos_dir(managed.id).is_dir()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bulk_delete_removes_photo_dirs_under_a_deleted_folder(
self, async_client: AsyncClient, file_factory, isolated_storage
):
# The folder branch of bulk-delete lets the cascade hard-delete every
# row in the subtree, so it owes the same photo cleanup the file
# branch above it does — including files nested a level down.
parent = await async_client.post("/api/v1/library/folders", json={"name": "Jigs"})
assert parent.status_code == 200
parent_id = parent.json()["id"]
child = await async_client.post("/api/v1/library/folders", json={"name": "V2", "parent_id": parent_id})
assert child.status_code == 200
top_file = await file_factory(folder_id=parent_id)
nested_file = await file_factory(folder_id=child.json()["id"])
for library_file in (top_file, nested_file):
assert (await _upload(async_client, library_file.id)).status_code == 200
assert library_photos_dir(library_file.id).is_dir()
response = await async_client.post(
"/api/v1/library/bulk-delete",
json={"file_ids": [], "folder_ids": [parent_id]},
)
assert response.status_code == 200
assert response.json()["deleted_folders"] == 1
assert (await async_client.get(f"/api/v1/library/files/{top_file.id}")).status_code == 404
assert not library_photos_dir(top_file.id).exists()
assert not library_photos_dir(nested_file.id).exists()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_deleting_a_user_with_their_items_removes_photo_dirs(
self, async_client: AsyncClient, db_session, file_factory, isolated_storage
):
# DELETE /users/{id}?delete_items=true bulk-deletes the rows, which is
# a hard delete like any other and owes the photos with it.
owner = User(username="photo-owner", password_hash="x", role="user")
db_session.add(owner)
await db_session.commit()
await db_session.refresh(owner)
owned = await file_factory(created_by_id=owner.id)
someone_elses = await file_factory()
for library_file in (owned, someone_elses):
assert (await _upload(async_client, library_file.id)).status_code == 200
assert library_photos_dir(library_file.id).is_dir()
response = await async_client.delete(f"/api/v1/users/{owner.id}?delete_items=true")
assert response.status_code == 204
assert (await async_client.get(f"/api/v1/library/files/{owned.id}")).status_code == 404
assert not library_photos_dir(owned.id).exists()
assert library_photos_dir(someone_elses.id).is_dir()
@pytest.fixture
def external_share(self, monkeypatch, tmp_path):
"""Bambuddy's data dir and an opted-in external share, as siblings.
The share cannot live under ``base_dir`` — ``_validate_external_path``
refuses to mount a Bambuddy-managed directory, and the module's
``isolated_storage`` points ``base_dir`` at ``tmp_path`` itself.
"""
data_dir = tmp_path / "data"
data_dir.mkdir()
monkeypatch.setattr(app_settings, "base_dir", data_dir)
monkeypatch.setattr(app_settings, "archive_dir", data_dir / "archive")
share = tmp_path / "share"
share.mkdir()
monkeypatch.setenv("BAMBUDDY_EXTERNAL_ROOTS", str(share))
return share
@pytest.mark.asyncio
@pytest.mark.integration
async def test_external_scan_removes_photo_dir_of_vanished_file(self, async_client: AsyncClient, external_share):
share = external_share
(share / "bracket.stl").write_bytes(b"fakestl")
folder = await async_client.post(
"/api/v1/library/folders/external",
json={"name": "Share", "external_path": str(share), "readonly": True, "show_hidden": False},
)
assert folder.status_code == 200
folder_id = folder.json()["id"]
scan = await async_client.post(f"/api/v1/library/folders/{folder_id}/scan")
assert scan.status_code == 200
assert scan.json()["added"] == 1
listing = await async_client.get(f"/api/v1/library/files?folder_id={folder_id}")
file_id = listing.json()[0]["id"]
assert (await _upload(async_client, file_id)).status_code == 200
photos_dir = library_photos_dir(file_id)
assert photos_dir.is_dir()
(share / "bracket.stl").unlink()
rescan = await async_client.post(f"/api/v1/library/folders/{folder_id}/scan")
assert rescan.status_code == 200
assert rescan.json()["removed"] == 1
assert not photos_dir.exists()
@@ -0,0 +1,171 @@
"""Integration tests for the client-rendered preview thumbnail upload (#2976).
STEP/PDF/spreadsheet previews render in the browser and post their first
render to POST /library/files/{id}/preview-thumbnail. These tests pin the
endpoint's contract: PNG-only, capped size, only for the client-preview file
types, and never replacing an existing thumbnail.
"""
import io
import pytest
from httpx import AsyncClient
from PIL import Image
from backend.app.core.config import settings as app_settings
from backend.app.models.library import LibraryFile
def _png_bytes(size: tuple[int, int] = (300, 300), color: str = "red") -> bytes:
buf = io.BytesIO()
Image.new("RGB", size, color).save(buf, "PNG")
return buf.getvalue()
@pytest.fixture
def isolated_storage(monkeypatch, tmp_path):
"""Point thumbnail storage at a throwaway directory."""
monkeypatch.setattr(app_settings, "base_dir", tmp_path)
monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
return tmp_path
@pytest.fixture
async def file_factory(db_session):
"""Factory for LibraryFile rows of arbitrary file_type."""
_counter = [0]
async def _create_file(**kwargs):
_counter[0] += 1
counter = _counter[0]
defaults = {
"filename": f"part{counter}.step",
"file_path": f"library/files/part{counter}.step",
"file_type": "step",
"file_size": 100,
}
defaults.update(kwargs)
library_file = LibraryFile(**defaults)
db_session.add(library_file)
await db_session.commit()
await db_session.refresh(library_file)
return library_file
return _create_file
class TestPreviewThumbnailUpload:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_sets_thumbnail_path(
self, async_client: AsyncClient, db_session, file_factory, isolated_storage
):
library_file = await file_factory(file_type="step")
response = await async_client.post(
f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
)
assert response.status_code == 200
assert response.json() == {"updated": True}
await db_session.refresh(library_file)
assert library_file.thumbnail_path
stored = isolated_storage / library_file.thumbnail_path
assert stored.exists()
with Image.open(stored) as img:
assert img.format == "PNG"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_downscales_oversized_image(
self, async_client: AsyncClient, db_session, file_factory, isolated_storage
):
library_file = await file_factory(file_type="pdf", filename="doc.pdf", file_path="library/files/doc.pdf")
response = await async_client.post(
f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
files={"thumbnail": ("preview.png", _png_bytes(size=(1024, 1024)), "image/png")},
)
assert response.status_code == 200
await db_session.refresh(library_file)
with Image.open(isolated_storage / library_file.thumbnail_path) as img:
assert max(img.size) <= 512
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_skips_when_thumbnail_exists(
self, async_client: AsyncClient, db_session, file_factory, isolated_storage
):
library_file = await file_factory(file_type="csv", thumbnail_path="archive/library/thumbnails/existing.png")
response = await async_client.post(
f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
)
assert response.status_code == 200
assert response.json() == {"updated": False}
await db_session.refresh(library_file)
assert library_file.thumbnail_path == "archive/library/thumbnails/existing.png"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_rejected_for_server_rendered_types(
self, async_client: AsyncClient, file_factory, isolated_storage
):
# STL thumbnails are generated server-side; the client route must not
# be able to overwrite them.
library_file = await file_factory(file_type="stl", filename="part.stl")
response = await async_client.post(
f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
)
assert response.status_code == 400
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_rejects_non_png(self, async_client: AsyncClient, file_factory, isolated_storage):
library_file = await file_factory(file_type="step")
buf = io.BytesIO()
Image.new("RGB", (64, 64), "blue").save(buf, "JPEG")
response = await async_client.post(
f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
files={"thumbnail": ("preview.png", buf.getvalue(), "image/png")},
)
assert response.status_code == 400
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_rejects_garbage_bytes(self, async_client: AsyncClient, file_factory, isolated_storage):
library_file = await file_factory(file_type="xlsx")
response = await async_client.post(
f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
files={"thumbnail": ("preview.png", b"not an image at all", "image/png")},
)
assert response.status_code == 400
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_rejects_oversized_payload(self, async_client: AsyncClient, file_factory, isolated_storage):
library_file = await file_factory(file_type="ods")
oversized = b"\x89PNG\r\n\x1a\n" + b"\x00" * (2 * 1024 * 1024)
response = await async_client.post(
f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
files={"thumbnail": ("preview.png", oversized, "image/png")},
)
assert response.status_code == 413
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_missing_file_returns_404(self, async_client: AsyncClient, isolated_storage):
response = await async_client.post(
"/api/v1/library/files/999999/preview-thumbnail",
files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
)
assert response.status_code == 404
@@ -944,6 +944,114 @@ class TestLibraryOwnershipPermissions(TestOwnershipPermissionsSetup):
assert response.status_code == 403
# ========================================================================
# Photo routes (#3077). Upload and delete are gated on LIBRARY_UPDATE_*,
# so a non-owner is refused with 403 exactly like ``update_file``. The
# read path goes through ``_ensure_library_file_visible`` and answers 404
# instead, so an id that exists tells an outsider nothing.
# ========================================================================
@pytest.fixture
def photo_storage(self, monkeypatch, tmp_path):
"""Keep uploaded photos out of the real data directory."""
from backend.app.core.config import settings as app_settings
monkeypatch.setattr(app_settings, "base_dir", tmp_path)
monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
return tmp_path
@staticmethod
def _photo_upload():
import io
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (8, 8), "blue").save(buf, "JPEG")
return {"file": ("result.jpg", buf.getvalue(), "image/jpeg")}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_operator_can_upload_photo_to_own_library_file(
self, async_client: AsyncClient, auth_setup, library_file_factory, photo_storage
):
file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
response = await async_client.post(
f"/api/v1/library/files/{file.id}/photos",
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
files=self._photo_upload(),
)
assert response.status_code == 200
assert len(response.json()["photos"]) == 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_operator_cannot_upload_photo_to_others_library_file(
self, async_client: AsyncClient, auth_setup, library_file_factory, photo_storage
):
from backend.app.utils.library_paths import library_photos_dir
file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
response = await async_client.post(
f"/api/v1/library/files/{file.id}/photos",
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
files=self._photo_upload(),
)
assert response.status_code == 403
assert not library_photos_dir(file.id).exists()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_operator_cannot_delete_photo_from_others_library_file(
self, async_client: AsyncClient, auth_setup, library_file_factory, photo_storage
):
from backend.app.utils.library_paths import library_photos_dir
file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
upload = await async_client.post(
f"/api/v1/library/files/{file.id}/photos",
headers={"Authorization": f"Bearer {auth_setup['operator2_token']}"},
files=self._photo_upload(),
)
filename = upload.json()["filename"]
response = await async_client.delete(
f"/api/v1/library/files/{file.id}/photos/{filename}",
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
)
assert response.status_code == 403
assert (library_photos_dir(file.id) / filename).is_file()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_operator_reading_others_library_file_photo_gets_404(
self, async_client: AsyncClient, auth_setup, library_file_factory, photo_storage
):
file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
upload = await async_client.post(
f"/api/v1/library/files/{file.id}/photos",
headers={"Authorization": f"Bearer {auth_setup['operator2_token']}"},
files=self._photo_upload(),
)
filename = upload.json()["filename"]
owner = await async_client.get(
f"/api/v1/library/files/{file.id}/photos/{filename}",
headers={"Authorization": f"Bearer {auth_setup['operator2_token']}"},
)
assert owner.status_code == 200
stranger = await async_client.get(
f"/api/v1/library/files/{file.id}/photos/{filename}",
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
)
assert stranger.status_code == 404
# ========================================================================
# Folder deletion (#1781): folders have no ownership tracking, so users
# with only library:delete_own may delete empty, non-external, non-linked
@@ -289,6 +289,61 @@ async def test_spa_csp_nonce_changes_per_request(async_client: AsyncClient):
assert len(nonces) == 5, f"nonces should be per-request, got {nonces!r}"
# ─── #2976: STEP preview needs WebAssembly, and only WebAssembly ─────────
@pytest.mark.asyncio
@pytest.mark.integration
async def test_spa_csp_allows_wasm_but_not_eval(async_client: AsyncClient):
"""script-src must carry 'wasm-unsafe-eval' but never 'unsafe-eval' (#2976).
The STEP preview triangulates in the browser via OpenCascade compiled to
WASM; without 'wasm-unsafe-eval' the nonce-based CSP blocks
WebAssembly.instantiate() and the preview dies with a CompileError.
'wasm-unsafe-eval' permits wasm compilation only — JS eval()/Function()
stay blocked, which is what the second assertion pins.
"""
resp = await async_client.get("/api/v1/auth/status")
csp = resp.headers.get("Content-Security-Policy", "")
script_src = next(
(d.strip() for d in csp.split(";") if d.strip().startswith("script-src")),
"",
)
assert "'wasm-unsafe-eval'" in script_src, f"script-src must allow wasm compilation: {script_src!r}"
# Substring check must not be fooled by 'wasm-unsafe-eval' containing
# "unsafe-eval" — compare whole tokens.
tokens = script_src.split()
assert "'unsafe-eval'" not in tokens, f"script-src must not allow JS eval: {script_src!r}"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_step_worker_asset_csp_relaxes_eval_only_for_that_file(async_client: AsyncClient):
"""Only the STEP worker script's own response may carry 'unsafe-eval' (#2976).
The occt-import-js embind glue generates invokers with `new Function`,
so the dedicated worker needs an eval-permitting policy. Per CSP3 a
worker is governed by the policy delivered with its own script response,
which confines eval to that DOM-less context. Any other asset — and the
SPA document itself — must stay nonce-strict. Both requests 404 in the
test checkout; the security middleware stamps headers regardless.
"""
def script_src_tokens(resp) -> list[str]:
csp = resp.headers.get("Content-Security-Policy", "")
directive = next(
(d.strip() for d in csp.split(";") if d.strip().startswith("script-src")),
"",
)
return directive.split()
worker = await async_client.get("/assets/stepPreview.worker-Ck9aB12c.js")
assert "'unsafe-eval'" in script_src_tokens(worker), "step worker script must be allowed to eval"
other = await async_client.get("/assets/index-Ck9aB12c.js")
assert "'unsafe-eval'" not in script_src_tokens(other), "ordinary assets must stay eval-free"
# ─── #1460: HEAD on PWA bootstrap routes (manifest / sw / sw-register) ───
@@ -0,0 +1,67 @@
"""``move_library_photos`` edge cases (#3077).
The happy path is pinned by the scheduler's cleanup tests; this covers what
they cannot reach — a name already taken in the destination, and a move that
fails halfway.
"""
import pytest
from backend.app.core.config import settings
from backend.app.utils.library_paths import library_photos_dir, move_library_photos
@pytest.fixture
def photo_dirs(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
destination = tmp_path / "archives" / "1" / "photos"
source = library_photos_dir(7)
source.mkdir(parents=True)
return source, destination
def test_moves_every_photo_and_drops_the_directory(photo_dirs):
source, destination = photo_dirs
(source / "a1b2c3d4.jpg").write_bytes(b"one")
(source / "e5f6a7b8.png").write_bytes(b"two")
moved = move_library_photos(7, ["a1b2c3d4.jpg", "e5f6a7b8.png"], destination)
assert moved == ["a1b2c3d4.jpg", "e5f6a7b8.png"]
assert (destination / "a1b2c3d4.jpg").read_bytes() == b"one"
assert not source.exists()
def test_renames_around_a_name_the_destination_already_holds(photo_dirs):
source, destination = photo_dirs
(source / "a1b2c3d4.jpg").write_bytes(b"library")
destination.mkdir(parents=True)
(destination / "a1b2c3d4.jpg").write_bytes(b"archive")
moved = move_library_photos(7, ["a1b2c3d4.jpg"], destination)
assert moved != ["a1b2c3d4.jpg"]
assert moved[0].endswith(".jpg")
assert (destination / "a1b2c3d4.jpg").read_bytes() == b"archive"
assert (destination / moved[0]).read_bytes() == b"library"
def test_a_traversal_name_is_skipped_and_keeps_the_directory(photo_dirs):
# A stored name is uuid-generated, so this only happens to a row someone
# has written to by hand — but the photos are then left alone rather than
# swept away by a cleanup that could not move them.
source, destination = photo_dirs
(source / "a1b2c3d4.jpg").write_bytes(b"one")
moved = move_library_photos(7, ["../escape.jpg", "a1b2c3d4.jpg"], destination)
assert moved == ["a1b2c3d4.jpg"]
assert source.is_dir()
def test_a_file_without_photos_is_a_no_op(photo_dirs):
source, destination = photo_dirs
source.rmdir()
assert move_library_photos(7, [], destination) == []
assert not destination.exists()
@@ -607,6 +607,7 @@ NOT_A_FETCH_TARGET = {
("SystemConfigRequest", "backend_url"),
("ExternalLinkCreate", "url"), # sidebar link, rendered in the UI, never requested
("ExternalLinkUpdate", "url"),
("FileUpdate", "external_url"), # library file link (#3077), rendered in the UI, never fetched
("MaintenanceTypeCreate", "wiki_url"), # documentation link surfaced in the UI/notifications
("MaintenanceTypeUpdate", "wiki_url"),
("ArchiveUpdate", "external_url"), # stored source link for the model, never fetched
@@ -1,10 +1,12 @@
import logging
from contextlib import ExitStack
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy import select
from sqlalchemy import event, select
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import backend.app.models # noqa: F401 - populate Base.metadata
@@ -15,6 +17,8 @@ from backend.app.models.library import LibraryFile
from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
from backend.app.models.printer import Printer
from backend.app.services.print_scheduler import PrintScheduler
from backend.app.utils.archive_paths import archive_photos_dir
from backend.app.utils.library_paths import library_photos_dir
from backend.tests._fixtures.background_tasks import discarding_spawn_patch
@@ -27,12 +31,13 @@ async def queue_factory(tmp_path):
session_maker = async_sessionmaker(engine, expire_on_commit=False)
case_counter = 0
async def make_case(*, cleanup=True, is_external=False, thumbnail_path=None, siblings=()):
async def make_case(*, cleanup=True, is_external=False, thumbnail_path=None, siblings=(), photos=()):
nonlocal case_counter
case_counter += 1
base_dir = tmp_path / f"case-{case_counter}"
base_dir.mkdir()
archive_dir = base_dir / "archive"
source_path = base_dir / "library" / f"source-{case_counter}.3mf"
source_path.parent.mkdir()
source_path.write_bytes(b"library source")
@@ -70,10 +75,21 @@ async def queue_factory(tmp_path):
thumbnail_path=thumbnail_db_path,
file_metadata=None,
is_external=is_external,
photos=list(photos) or None,
)
db.add_all([printer, library_file])
await db.flush()
# Photos of the printed result (#3077). Written through the real
# helper so the test cannot drift from the layout the code uses.
photos_dir = None
if photos:
with patch.object(scheduler_module.settings, "archive_dir", archive_dir):
photos_dir = library_photos_dir(library_file.id)
photos_dir.mkdir(parents=True, exist_ok=True)
for name in photos:
(photos_dir / name).write_bytes(f"photo {name}".encode())
item = PrintQueueItem(
printer_id=printer.id,
library_file_id=library_file.id,
@@ -152,7 +168,10 @@ async def queue_factory(tmp_path):
return SimpleNamespace(
session_maker=session_maker,
base_dir=base_dir,
archive_dir=archive_dir,
source_path=source_path,
photos_dir=photos_dir,
photo_names=list(photos),
thumbnail_path=thumbnail_actual_path,
printer_id=printer.id,
library_file_id=library_file.id,
@@ -170,7 +189,14 @@ async def queue_factory(tmp_path):
await engine.dispose()
async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effect=None):
async def _dispatch_library_item(
ctx,
*,
archive_failure=False,
unlink_side_effect=None,
cleanup_commit_failure=False,
photo_commit_failure=False,
):
scheduler = PrintScheduler()
async def archive_print(
@@ -213,6 +239,7 @@ async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effe
patches = [
patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
patch.object(scheduler_module.settings, "archive_dir", ctx.archive_dir),
patch("backend.app.services.archive.ArchiveService.archive_print", new=archive_print),
patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
@@ -239,10 +266,99 @@ async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effe
stack.enter_context(patcher)
async with ctx.session_maker() as db:
if cleanup_commit_failure:
_arm_commit_failure_on_library_delete(db)
if photo_commit_failure:
_arm_commit_failure_on_photo_move(db, stack)
item = await db.get(PrintQueueItem, ctx.queue_item_id)
await scheduler._start_print(db, item)
def _fail_inside_the_next_flush(db, statement):
"""Make the next flush on this session fail, once, the way SQLite does.
Raising *instead of* calling `db.commit()` does not reproduce a failed
commit and is not the dangerous case: the session stays ACTIVE, nothing is
expired, and every loaded instance still reads out of `__dict__`. What a
busy writer actually gives you is a statement error raised inside the
flush — SQLite takes the write lock at the first DML statement, not at
COMMIT, so "database is locked" surfaces there (#1853). SQLAlchemy rolls
that back internally through `safe_reraise` before re-raising, which
expires every loaded instance and leaves the session in pending-rollback
state: the next ORM attribute read raises PendingRollbackError, *before*
the handler's own rollback can run. That is the state a handler on this
path has to survive, so it is the state these tests have to produce.
"""
sync_session = db.sync_session
fired = False
def after_flush(session, flush_context):
nonlocal fired
if fired:
return
fired = True
raise OperationalError(statement, {}, Exception("database is locked"))
event.listen(sync_session, "after_flush", after_flush)
def _arm_commit_failure_on_library_delete(db):
"""Make the one commit that removes the library row fail, once.
Stands in for the "database is locked" cascades the commit's own comment
cites (#1853). Armed by the delete rather than by a call count so it
cannot drift onto a different commit.
"""
original_delete = db.delete
original_commit = db.commit
armed = False
async def delete(obj):
nonlocal armed
if isinstance(obj, LibraryFile):
armed = True
return await original_delete(obj)
async def commit():
nonlocal armed
if armed:
armed = False
_fail_inside_the_next_flush(db, "DELETE FROM library_files WHERE library_files.id = ?")
return await original_commit()
db.delete = delete
db.commit = commit
def _arm_commit_failure_on_photo_move(db, stack):
"""Make the commit that records the carried photos fail, once.
The second commit of this path (#3077): the archive and the delete are
already committed, the pictures are already on disk under the archive,
and only `archive.photos` is pending. Armed by the move itself so it
cannot drift onto the delete's commit.
"""
original_commit = db.commit
original_move = scheduler_module.move_library_photos
armed = False
def move_library_photos(file_id, photos, destination):
nonlocal armed
carried = original_move(file_id, photos, destination)
armed = bool(carried)
return carried
async def commit():
nonlocal armed
if armed:
armed = False
_fail_inside_the_next_flush(db, "UPDATE print_archives SET photos=? WHERE print_archives.id = ?")
return await original_commit()
stack.enter_context(patch.object(scheduler_module, "move_library_photos", move_library_photos))
db.commit = commit
async def _queue_snapshot(ctx):
async with ctx.session_maker() as db:
item = await db.get(PrintQueueItem, ctx.queue_item_id)
@@ -279,6 +395,115 @@ async def test_external_library_file_skips_cleanup(queue_factory):
assert ctx.source_path.exists()
@pytest.mark.asyncio
async def test_cleanup_moves_the_photos_into_the_archive(queue_factory):
"""Photos follow the consumed file into the archive that replaces it (#3077).
The row is hard-deleted here, so leaving the photo directory alone
orphaned it under an id nothing points at any more — and the pictures
of a print that still has a record disappeared from the UI.
"""
ctx = await queue_factory(cleanup=True, photos=["a1b2c3d4.jpg", "e5f6a7b8.png"])
await _dispatch_library_item(ctx)
_, library_file, archive = await _queue_snapshot(ctx)
assert library_file is None
assert not ctx.photos_dir.exists()
assert archive.photos == ctx.photo_names
with patch.object(scheduler_module.settings, "base_dir", ctx.base_dir):
destination = archive_photos_dir(archive)
for name in ctx.photo_names:
assert (destination / name).read_bytes() == f"photo {name}".encode()
@pytest.mark.asyncio
async def test_external_library_file_keeps_its_photos(queue_factory):
ctx = await queue_factory(cleanup=True, is_external=True, photos=["a1b2c3d4.jpg"])
await _dispatch_library_item(ctx)
_, library_file, archive = await _queue_snapshot(ctx)
assert library_file is not None
assert (ctx.photos_dir / "a1b2c3d4.jpg").is_file()
assert archive.photos is None
@pytest.mark.asyncio
async def test_archive_creation_failure_keeps_the_photos(queue_factory):
ctx = await queue_factory(cleanup=True, photos=["a1b2c3d4.jpg"])
await _dispatch_library_item(ctx, archive_failure=True)
_, library_file, archive = await _queue_snapshot(ctx)
assert archive is None
assert library_file is not None
assert (ctx.photos_dir / "a1b2c3d4.jpg").is_file()
@pytest.mark.asyncio
async def test_cleanup_commit_failure_keeps_the_photos_with_the_library_file(queue_factory):
"""The photos move after the delete commits, not before it (#3077).
The commit that removes the library row can fail; the except branch rolls
it back and the file is in the library again. Photos moved ahead of that
commit would be gone from under it — the row would name a directory that
no longer exists, and the pictures would sit under an archive that was
rolled back too.
"""
ctx = await queue_factory(cleanup=True, photos=["a1b2c3d4.jpg"])
await _dispatch_library_item(ctx, cleanup_commit_failure=True)
item, library_file, archive = await _queue_snapshot(ctx)
assert item.status == "failed"
assert archive is None
assert library_file is not None
assert library_file.photos == ["a1b2c3d4.jpg"]
assert (ctx.photos_dir / "a1b2c3d4.jpg").read_bytes() == b"photo a1b2c3d4.jpg"
assert not (ctx.base_dir / "archives" / "photos").exists()
@pytest.mark.asyncio
async def test_photo_commit_failure_still_dispatches_the_print(queue_factory, caplog):
"""A failed photos commit must not take the dispatch down with it (#3077).
The archive and the delete are committed by then, so the print goes ahead
and the pictures sit unnamed under the archive.
The commit fails inside the flush, which is where a locked SQLite fails —
see `_fail_inside_the_next_flush`. That expires every loaded instance
twice over: once by SQLAlchemy's internal rollback, before the handler
runs at all, and again at the handler's own `rollback()`. So the handler
may not read an ORM attribute on either side of that rollback. Before it,
a read raises PendingRollbackError; after it, MissingGreenlet — the nozzle
guard's `archive.nozzle_diameter` and the upload's `printer.name` are the
ones that used to die.
"""
ctx = await queue_factory(cleanup=True, photos=["a1b2c3d4.jpg"])
with caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"):
await _dispatch_library_item(ctx, photo_commit_failure=True)
item, library_file, archive = await _queue_snapshot(ctx)
# The handler absorbed it rather than the failure being skipped: it names
# the queue item and the archive from ints it held before the commit.
assert any(
f"Queue item {ctx.queue_item_id}: failed to carry library photos into archive {item.archive_id}"
in record.message
for record in caplog.records
)
assert item.status == "printing"
assert item.archive_id == archive.id
assert library_file is None
assert not archive.photos
ctx.upload.assert_awaited()
ctx.start_print.assert_called()
with patch.object(scheduler_module.settings, "base_dir", ctx.base_dir):
destination = archive_photos_dir(archive)
assert (destination / "a1b2c3d4.jpg").read_bytes() == b"photo a1b2c3d4.jpg"
@pytest.mark.asyncio
async def test_archive_creation_failure_skips_cleanup_and_dispatch(queue_factory):
ctx = await queue_factory(cleanup=True, thumbnail_path="relative")