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"