| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436 |
- """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()
|