From 62fbd54f15710a05926cf6255d799508b60ce8c9 Mon Sep 17 00:00:00 2001 From: Anshul Jain Date: Sat, 11 Jul 2026 18:23:04 +0530 Subject: [PATCH 1/3] fix: cap files accepted by POST /api/upload Multipart upload route was missing the file-count limit already applied to ZIP bulk uploads. Add pre-processing check using existing MAX_BULK_FILES setting, rejecting over-limit requests with HTTP 413 Payload Too Large before any file processing begins. Fixes #288 --- backend/src/find_api/routers/upload.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/src/find_api/routers/upload.py b/backend/src/find_api/routers/upload.py index 760f0ba2..5e875206 100644 --- a/backend/src/find_api/routers/upload.py +++ b/backend/src/find_api/routers/upload.py @@ -40,6 +40,12 @@ async def upload_images( Returns: List of created media records with job IDs """ + if len(files) > settings.MAX_BULK_FILES: + raise HTTPException( + 413, + f"Request contains more than {settings.MAX_BULK_FILES} files", + ) + results = [] for file in files: From da3e5cdb6ae36ae9170f0b15114e58491da3b3a3 Mon Sep 17 00:00:00 2001 From: Anshul Jain Date: Sun, 12 Jul 2026 11:43:32 +0530 Subject: [PATCH 2/3] test: add boundary tests for MAX_BULK_FILES Add comprehensive tests for file count boundary conditions: - Below MAX_BULK_FILES: verify multiple files succeed - At MAX_BULK_FILES: verify exactly at limit succeeds - Above MAX_BULK_FILES: verify exceeding limit is rejected These tests ensure the guard logic in the upload endpoint properly validates file count constraints without allowing uploads that exceed the limit. Addresses maintainer feedback on PR #351. --- backend/tests/test_upload.py | 53 ++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/backend/tests/test_upload.py b/backend/tests/test_upload.py index 741aa5fa..01138f1b 100644 --- a/backend/tests/test_upload.py +++ b/backend/tests/test_upload.py @@ -239,3 +239,56 @@ def test_bulk_upload_oversized_file_skipped(self, client): huge = next(r for r in results if r["filename"] == "huge.jpg") assert huge["status"] == "failed" assert "exceeds max upload size" in huge["error"].lower() + + def test_bulk_upload_below_max_files(self, client): + """ZIP with file count below MAX_BULK_FILES should succeed.""" + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED) as zf: + for i in range(5): + zf.writestr(f"image{i}.png", get_valid_image_bytes()) + zip_buffer.seek(0) + + response = client.post( + "/api/upload/bulk", + files=[("file", ("images.zip", zip_buffer.read(), "application/zip"))], + ) + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 5 + assert all(r["status"] == "uploaded" for r in results) + + def test_bulk_upload_at_max_files(self, client): + """ZIP with exactly MAX_BULK_FILES should succeed.""" + from find_api.settings import settings + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED) as zf: + for i in range(settings.MAX_BULK_FILES): + zf.writestr(f"image{i}.png", get_valid_image_bytes()) + zip_buffer.seek(0) + + response = client.post( + "/api/upload/bulk", + files=[("file", ("images.zip", zip_buffer.read(), "application/zip"))], + ) + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == settings.MAX_BULK_FILES + assert all(r["status"] == "uploaded" for r in results) + + def test_bulk_upload_above_max_files(self, client): + """ZIP with file count exceeding MAX_BULK_FILES should be rejected.""" + from find_api.settings import settings + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED) as zf: + for i in range(settings.MAX_BULK_FILES + 1): + zf.writestr(f"image{i}.png", get_valid_image_bytes()) + zip_buffer.seek(0) + + response = client.post( + "/api/upload/bulk", + files=[("file", ("images.zip", zip_buffer.read(), "application/zip"))], + ) + assert response.status_code == 400 + assert "files" in response.json()["detail"].lower() and "limit" in response.json()["detail"].lower() From f36802eb36272bf6b97e3e2d3fc20cdea16b0cb2 Mon Sep 17 00:00:00 2001 From: Abhash Chakraborty <80592559+Abhash-Chakraborty@users.noreply.github.com> Date: Mon, 13 Jul 2026 01:30:04 +0530 Subject: [PATCH 3/3] test: cover multipart upload count boundaries --- backend/tests/test_upload.py | 104 +++++++++++++++++------------------ 1 file changed, 51 insertions(+), 53 deletions(-) diff --git a/backend/tests/test_upload.py b/backend/tests/test_upload.py index 01138f1b..5e7d252f 100644 --- a/backend/tests/test_upload.py +++ b/backend/tests/test_upload.py @@ -114,6 +114,57 @@ def test_missing_files_returns_422(self, client): assert response.status_code == 422 +class TestMultipartUploadLimit: + """The multipart endpoint enforces MAX_BULK_FILES before ingestion.""" + + @staticmethod + def _files(count: int): + data = get_valid_image_bytes() + return [ + ("files", (f"photo-{index}.png", data, "image/png")) + for index in range(count) + ] + + def test_below_limit_proceeds(self, client): + with ( + patch("find_api.routers.upload.settings.MAX_BULK_FILES", 3), + patch( + "find_api.routers.upload._ingest_image", + return_value={"status": "uploaded"}, + ) as ingest, + ): + response = client.post("/api/upload", files=self._files(2)) + + assert response.status_code == 200 + assert response.json()["total"] == 2 + assert ingest.call_count == 2 + + def test_exact_limit_proceeds(self, client): + with ( + patch("find_api.routers.upload.settings.MAX_BULK_FILES", 3), + patch( + "find_api.routers.upload._ingest_image", + return_value={"status": "uploaded"}, + ) as ingest, + ): + response = client.post("/api/upload", files=self._files(3)) + + assert response.status_code == 200 + assert response.json()["total"] == 3 + assert ingest.call_count == 3 + + def test_above_limit_is_rejected_before_ingestion(self, client): + with ( + patch("find_api.routers.upload.settings.MAX_BULK_FILES", 3), + patch("find_api.routers.upload._ingest_image") as ingest, + ): + response = client.post("/api/upload", files=self._files(4)) + + assert response.status_code == 413 + assert response.json()["detail"] == "Request contains more than 3 files" + ingest.assert_not_called() + + class TestBulkUpload: """Bulk ZIP upload behavior.""" @@ -239,56 +290,3 @@ def test_bulk_upload_oversized_file_skipped(self, client): huge = next(r for r in results if r["filename"] == "huge.jpg") assert huge["status"] == "failed" assert "exceeds max upload size" in huge["error"].lower() - - def test_bulk_upload_below_max_files(self, client): - """ZIP with file count below MAX_BULK_FILES should succeed.""" - zip_buffer = io.BytesIO() - with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED) as zf: - for i in range(5): - zf.writestr(f"image{i}.png", get_valid_image_bytes()) - zip_buffer.seek(0) - - response = client.post( - "/api/upload/bulk", - files=[("file", ("images.zip", zip_buffer.read(), "application/zip"))], - ) - assert response.status_code == 200 - results = response.json()["results"] - assert len(results) == 5 - assert all(r["status"] == "uploaded" for r in results) - - def test_bulk_upload_at_max_files(self, client): - """ZIP with exactly MAX_BULK_FILES should succeed.""" - from find_api.settings import settings - - zip_buffer = io.BytesIO() - with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED) as zf: - for i in range(settings.MAX_BULK_FILES): - zf.writestr(f"image{i}.png", get_valid_image_bytes()) - zip_buffer.seek(0) - - response = client.post( - "/api/upload/bulk", - files=[("file", ("images.zip", zip_buffer.read(), "application/zip"))], - ) - assert response.status_code == 200 - results = response.json()["results"] - assert len(results) == settings.MAX_BULK_FILES - assert all(r["status"] == "uploaded" for r in results) - - def test_bulk_upload_above_max_files(self, client): - """ZIP with file count exceeding MAX_BULK_FILES should be rejected.""" - from find_api.settings import settings - - zip_buffer = io.BytesIO() - with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED) as zf: - for i in range(settings.MAX_BULK_FILES + 1): - zf.writestr(f"image{i}.png", get_valid_image_bytes()) - zip_buffer.seek(0) - - response = client.post( - "/api/upload/bulk", - files=[("file", ("images.zip", zip_buffer.read(), "application/zip"))], - ) - assert response.status_code == 400 - assert "files" in response.json()["detail"].lower() and "limit" in response.json()["detail"].lower()