diff --git a/src/backend/api/routers/vision.py b/src/backend/api/routers/vision.py index 45f1c7c..9dcfab3 100644 --- a/src/backend/api/routers/vision.py +++ b/src/backend/api/routers/vision.py @@ -40,14 +40,22 @@ _IMAGE_EXTENSIONS = { "image/png": "png", "image/gif": "gif", "image/webp": "webp", + "image/bmp": "bmp", # #99 + "image/x-ms-bmp": "bmp", # #99 } +# #99: the Opto bench cameras save BMP and the worker decodes it (PIL), so the +# vision routes accept it on top of files.py's list. files.py is untouched: its +# attachments are served by extension and BMP is not meant to be shown there. +_VISION_IMAGE_TYPES = ALLOWED_IMAGE_TYPES | {"image/bmp", "image/x-ms-bmp"} + # Sniffed from the first bytes, independent of the declared Content-Type: a # text file uploaded as "image/png" passes the header check above and must -# still be caught before it reaches the worker as a bare 500 (#15). BMP and -# TIFF are included even though `_IMAGE_EXTENSIONS` does not accept them, -# because PIL - and so the worker - can decode both; this check only refuses -# what is not a real image at all, it does not narrow the allow-list above. +# still be caught before it reaches the worker as a bare 500 (#15). TIFF is +# included even though the allow-list does not accept it (PIL, and so the +# worker, can decode it; browsers do not show it in the result); BMP is now +# also allowed (#99). This check only refuses what is not a real image at all, +# it does not narrow the allow-list above. _IMAGE_SIGNATURES: tuple[tuple[bytes, ...], ...] = ( (b"\x89PNG\r\n\x1a\n",), # PNG (b"\xff\xd8\xff",), # JPEG @@ -83,7 +91,7 @@ async def _read_image(image: UploadFile) -> bytes: is in hand - the same two-step convention `files.py::upload_file` already uses, reused rather than reinvented here. """ - if image.content_type not in ALLOWED_IMAGE_TYPES: + if image.content_type not in _VISION_IMAGE_TYPES: # #99 raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"File type {image.content_type} not allowed. Must be an image.", diff --git a/src/backend/tests/test_vision_bmp.py b/src/backend/tests/test_vision_bmp.py new file mode 100644 index 0000000..c780584 --- /dev/null +++ b/src/backend/tests/test_vision_bmp.py @@ -0,0 +1,109 @@ +"""#99: the vision routes accept BMP (the Opto bench cameras save it); TIFF and +non-images stay refused. files.py (attachments) is not touched by this.""" +import io + +import pytest +from PIL import Image +from sqlalchemy import select + +from src.backend.config import settings +from src.backend.models.orm.vision import VisionReferenceImage +from src.backend.tests.conftest import auth_headers, create_test_recipe +from src.backend.tests.test_vision_upload_limits import ( # noqa: F401 - fixtures + recipe_parts, redirect_uploads, worker_says, +) + + +def a_bmp() -> bytes: + buffer = io.BytesIO() + Image.new("L", (64, 64), color=0).save(buffer, format="BMP") + return buffer.getvalue() + + +def a_tiff() -> bytes: + buffer = io.BytesIO() + Image.new("L", (64, 64), color=0).save(buffer, format="TIFF") + return buffer.getvalue() + + +async def a_camera_task(db_session, user): + recipe = await create_test_recipe(db_session, user.id) + _, task, _ = await recipe_parts(db_session, recipe.id) + task.task_type = "camera_measure" + task.vision_json = {"schema_version": 1, "name": "p", "tools": []} + await db_session.commit() + return task + + +@pytest.mark.asyncio +@pytest.mark.parametrize("content_type", ["image/bmp", "image/x-ms-bmp"]) +async def test_preview_accepts_a_bmp( + client, db_session, maker_user, worker_says, content_type, +): + task = await a_camera_task(db_session, maker_user) + worker_says({"t1.diameter": 10.0}) + + response = await client.post( + "/api/vision/preview", + headers=auth_headers(maker_user), + files={"image": ("p.bmp", a_bmp(), content_type)}, + data={"task_id": str(task.id)}, + ) + + assert response.status_code != 400, response.text + assert response.status_code != 422, response.text + + +@pytest.mark.asyncio +async def test_preview_still_rejects_a_tiff( + client, db_session, maker_user, worker_says, +): + task = await a_camera_task(db_session, maker_user) + worker_says({"t1.diameter": 10.0}) + + response = await client.post( + "/api/vision/preview", + headers=auth_headers(maker_user), + files={"image": ("p.tif", a_tiff(), "image/tiff")}, + data={"task_id": str(task.id)}, + ) + + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_a_text_file_declared_bmp_is_refused_on_its_bytes( + client, db_session, maker_user, worker_says, +): + task = await a_camera_task(db_session, maker_user) + worker_says({"t1.diameter": 10.0}) + + response = await client.post( + "/api/vision/preview", + headers=auth_headers(maker_user), + files={"image": ("p.bmp", b"i am not an image", "image/bmp")}, + data={"task_id": str(task.id)}, + ) + + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_reference_image_bmp_is_saved_with_the_bmp_extension( + client, db_session, maker_user, +): + recipe = await create_test_recipe(db_session, maker_user.id) + _, task, _ = await recipe_parts(db_session, recipe.id) + await db_session.commit() + + response = await client.post( + "/api/vision/reference-images", + headers=auth_headers(maker_user), + files={"image": ("p.bmp", a_bmp(), "image/x-ms-bmp")}, + data={"task_id": str(task.id)}, + ) + + assert response.status_code == 200, response.text + saved = (await db_session.execute(select(VisionReferenceImage))).scalars().one() + assert saved.path.endswith(".bmp") + assert (settings.upload_path / saved.path).exists()