mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 17:50:12 +02:00
File Manager: external link, notes and photos on library files (#3128)
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user