test(backend): cover artwork upload validation for roms and collections

Adds rejection + acceptance tests for update_rom, add_collection, and
update_collection artwork uploads, mirroring the existing avatar tests:
non-image content returns 400, and a real PNG uploaded under a misleading
filename like payload.html is stored with the trusted .png extension.

Also fixes two `return HTTPException(...)` → `raise` in raw.py so the 404
path actually surfaces instead of silently returning the exception object.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Georges-Antoine Assi
2026-05-09 09:37:44 -04:00
parent 53f14f5710
commit 783d9a257e
3 changed files with 138 additions and 2 deletions

View File

@@ -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)

View File

@@ -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"<script>alert(1)</script>", "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",

View File

@@ -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"<script>alert(1)</script>", "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"<script>alert(1)</script>", "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"