diff --git a/backend/endpoints/roms/__init__.py b/backend/endpoints/roms/__init__.py index 6374a4436..06f2407b0 100644 --- a/backend/endpoints/roms/__init__.py +++ b/backend/endpoints/roms/__init__.py @@ -868,6 +868,12 @@ async def head_rom_content( files = [f for f in files if f.id in file_id_values] files.sort(key=lambda x: x.file_name) + if not files: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"No files found for ROM {id}", + ) + # Serve the file directly in development mode for emulatorjs if DEV_MODE: if len(files) == 1: @@ -945,6 +951,12 @@ async def get_rom_content( files = [f for f in files if f.id in file_id_values] files.sort(key=lambda x: x.file_name) + if not files: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"No files found for ROM {id}", + ) + log.info( f"User {hl(current_username, color=BLUE)} is downloading {hl(rom.fs_name)}" ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 26dd58512..c44efeee1 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -89,6 +89,18 @@ def rom(admin_user: User, platform: Platform): return rom +@pytest.fixture +def rom_file(rom: Rom): + """A single content file attached to the `rom` fixture.""" + rom_file = RomFile( + rom_id=rom.id, + file_name="test_rom.zip", + file_path=rom.fs_path, + file_size_bytes=1000, + ) + return db_rom_handler.add_rom_file(rom_file) + + @pytest.fixture def multi_file_rom(admin_user: User, platform: Platform): """A ROM stored as a game folder with multiple files (e.g. multi-disc). diff --git a/backend/tests/endpoints/roms/test_rom.py b/backend/tests/endpoints/roms/test_rom.py index b0c06f59e..d7193f364 100644 --- a/backend/tests/endpoints/roms/test_rom.py +++ b/backend/tests/endpoints/roms/test_rom.py @@ -82,6 +82,62 @@ def test_get_all_roms( assert items[0]["id"] == rom.id +def test_get_rom_content_requires_auth(client: TestClient, rom: Rom, rom_file): + response = client.get(f"/api/roms/{rom.id}/content/test_rom.zip") + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_get_rom_content_single_file( + client: TestClient, access_token: str, rom: Rom, rom_file +): + response = client.get( + f"/api/roms/{rom.id}/content/test_rom.zip", + headers={"Authorization": f"Bearer {access_token}"}, + follow_redirects=False, + ) + assert response.status_code == status.HTTP_200_OK + # Single-file roms are proxied through nginx via X-Accel-Redirect. + assert "X-Accel-Redirect" in response.headers + + +def test_get_rom_content_valid_file_id( + client: TestClient, access_token: str, rom: Rom, rom_file +): + response = client.get( + f"/api/roms/{rom.id}/content/test_rom.zip", + headers={"Authorization": f"Bearer {access_token}"}, + params={"file_ids": str(rom_file.id)}, + follow_redirects=False, + ) + assert response.status_code == status.HTTP_200_OK + assert "X-Accel-Redirect" in response.headers + + +def test_get_rom_content_stale_file_id_returns_404( + client: TestClient, access_token: str, rom: Rom, rom_file +): + # Regression test for #3470: a remembered file id that no longer exists + # (e.g. after a rename gave the file a new id) must return a clean 404 + # instead of an empty-.m3u ZIP that nginx aborts as a 0-byte response. + stale_file_id = rom_file.id + 999 + response = client.get( + f"/api/roms/{rom.id}/content/test_rom.zip", + headers={"Authorization": f"Bearer {access_token}"}, + params={"file_ids": str(stale_file_id)}, + follow_redirects=False, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_get_rom_content_missing_rom_returns_404(client: TestClient, access_token: str): + response = client.get( + "/api/roms/999999/content/missing.zip", + headers={"Authorization": f"Bearer {access_token}"}, + follow_redirects=False, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @patch.object(FSRomsHandler, "rename_fs_rom") @patch.object(IGDBHandler, "get_rom_by_id", return_value=IGDBRom(igdb_id=None)) def test_update_rom(