diff --git a/backend/endpoints/raw.py b/backend/endpoints/raw.py
index 6f2d25ad4..1f4ede209 100644
--- a/backend/endpoints/raw.py
+++ b/backend/endpoints/raw.py
@@ -43,7 +43,7 @@ def head_raw_asset(request: Request, path: str):
# Check if file exists and is a file (not directory)
if not resolved_path.exists() or not resolved_path.is_file():
- return HTTPException(status_code=404, detail="Asset not found")
+ raise HTTPException(status_code=404, detail="Asset not found")
return _build_asset_response(resolved_path)
@@ -69,6 +69,6 @@ def get_raw_asset(request: Request, path: str):
# Check if file exists and is a file (not directory)
if not resolved_path.exists() or not resolved_path.is_file():
- return HTTPException(status_code=404, detail="Asset not found")
+ raise HTTPException(status_code=404, detail="Asset not found")
return _build_asset_response(resolved_path)
diff --git a/backend/tests/endpoints/roms/test_rom.py b/backend/tests/endpoints/roms/test_rom.py
index b0459c0e9..498c5593b 100644
--- a/backend/tests/endpoints/roms/test_rom.py
+++ b/backend/tests/endpoints/roms/test_rom.py
@@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch
from fastapi import status
from fastapi.testclient import TestClient
+from handler.filesystem.resources_handler import FSResourcesHandler
from handler.filesystem.roms_handler import FSRomsHandler
from handler.metadata.flashpoint_handler import FlashpointHandler, FlashpointRom
from handler.metadata.igdb_handler import IGDBHandler, IGDBRom
@@ -127,6 +128,54 @@ def test_update_rom_reparses_tags_on_fs_name_change(
assert body["tags"] == []
+# Minimal valid PNG (1x1 transparent pixel)
+_PNG_BYTES = (
+ b"\x89PNG\r\n\x1a\n"
+ b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
+ b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89"
+ b"\x00\x00\x00\rIDATx\x9cc\xfc\xff\xff?\x00\x05\xfe\x02\xfe\xa75\x81\x84"
+ b"\x00\x00\x00\x00IEND\xaeB`\x82"
+)
+
+
+def test_update_rom_rejects_non_image_artwork(
+ client: TestClient, access_token: str, rom: Rom
+):
+ response = client.put(
+ f"/api/roms/{rom.id}",
+ headers={"Authorization": f"Bearer {access_token}"},
+ files={"artwork": ("cover.png", b"", "image/png")},
+ )
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert "PNG, JPEG, WebP, or GIF" in response.json()["detail"]
+
+
+@patch.object(
+ FSResourcesHandler,
+ "store_artwork",
+ new_callable=AsyncMock,
+ return_value=("path/to/big.png", "path/to/small.png"),
+)
+def test_update_rom_artwork_uses_detected_extension(
+ store_artwork_mock: AsyncMock,
+ client: TestClient,
+ access_token: str,
+ rom: Rom,
+):
+ response = client.put(
+ f"/api/roms/{rom.id}",
+ headers={"Authorization": f"Bearer {access_token}"},
+ files={"artwork": ("payload.html", _PNG_BYTES, "image/png")},
+ )
+ assert response.status_code == status.HTTP_200_OK
+
+ # The handler is called with the trusted extension from libmagic, not the
+ # extension parsed off the user-supplied filename.
+ assert store_artwork_mock.called
+ _, _, file_ext = store_artwork_mock.call_args.args
+ assert file_ext == "png"
+
+
def test_delete_roms(client: TestClient, access_token: str, rom: Rom):
response = client.post(
"/api/roms/delete",
diff --git a/backend/tests/endpoints/test_collection.py b/backend/tests/endpoints/test_collection.py
index a21b53123..c632e126f 100644
--- a/backend/tests/endpoints/test_collection.py
+++ b/backend/tests/endpoints/test_collection.py
@@ -1,4 +1,5 @@
from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock, patch
import pytest
from fastapi import status
@@ -6,11 +7,21 @@ from fastapi import status
from config import OAUTH_ACCESS_TOKEN_EXPIRE_SECONDS
from handler.auth import oauth_handler
from handler.database import db_collection_handler, db_rom_handler
+from handler.filesystem.resources_handler import FSResourcesHandler
from models.collection import Collection
from models.platform import Platform
from models.rom import Rom
from models.user import User
+# Minimal valid PNG (1x1 transparent pixel)
+_PNG_BYTES = (
+ b"\x89PNG\r\n\x1a\n"
+ b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
+ b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89"
+ b"\x00\x00\x00\rIDATx\x9cc\xfc\xff\xff?\x00\x05\xfe\x02\xfe\xa75\x81\x84"
+ b"\x00\x00\x00\x00IEND\xaeB`\x82"
+)
+
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@@ -564,3 +575,79 @@ class TestAtomicBehavior:
refreshed = db_collection_handler.get_collection(collection.id)
assert refreshed is not None
assert set(refreshed.rom_ids) == {rom.id, second_rom.id}
+
+
+# ---------------------------------------------------------------------------
+# Artwork upload validation
+# ---------------------------------------------------------------------------
+
+
+class TestArtworkUpload:
+ def test_add_collection_rejects_non_image_artwork(self, client, access_token: str):
+ response = client.post(
+ "/api/collections",
+ data={"name": "Bad Cover Collection"},
+ files={"artwork": ("cover.png", b"", "image/png")},
+ headers={"Authorization": f"Bearer {access_token}"},
+ )
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert "PNG, JPEG, WebP, or GIF" in response.json()["detail"]
+
+ @patch.object(
+ FSResourcesHandler,
+ "store_artwork",
+ new_callable=AsyncMock,
+ return_value=("path/to/big.png", "path/to/small.png"),
+ )
+ def test_add_collection_artwork_uses_detected_extension(
+ self,
+ store_artwork_mock: AsyncMock,
+ client,
+ access_token: str,
+ ):
+ response = client.post(
+ "/api/collections",
+ data={"name": "Good Cover Collection"},
+ files={"artwork": ("payload.html", _PNG_BYTES, "image/png")},
+ headers={"Authorization": f"Bearer {access_token}"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert store_artwork_mock.called
+ _, _, file_ext = store_artwork_mock.call_args.args
+ assert file_ext == "png"
+
+ def test_update_collection_rejects_non_image_artwork(
+ self, client, access_token: str, collection: Collection
+ ):
+ response = client.put(
+ f"/api/collections/{collection.id}",
+ data={"rom_ids": "[]"},
+ files={"artwork": ("cover.png", b"", "image/png")},
+ headers={"Authorization": f"Bearer {access_token}"},
+ )
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert "PNG, JPEG, WebP, or GIF" in response.json()["detail"]
+
+ @patch.object(
+ FSResourcesHandler,
+ "store_artwork",
+ new_callable=AsyncMock,
+ return_value=("path/to/big.png", "path/to/small.png"),
+ )
+ def test_update_collection_artwork_uses_detected_extension(
+ self,
+ store_artwork_mock: AsyncMock,
+ client,
+ access_token: str,
+ collection: Collection,
+ ):
+ response = client.put(
+ f"/api/collections/{collection.id}",
+ data={"rom_ids": "[]"},
+ files={"artwork": ("payload.html", _PNG_BYTES, "image/png")},
+ headers={"Authorization": f"Bearer {access_token}"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert store_artwork_mock.called
+ _, _, file_ext = store_artwork_mock.call_args.args
+ assert file_ext == "png"