diff --git a/apps/api/app/services/demo/shared_document_service.py b/apps/api/app/services/demo/shared_document_service.py index eb6699c82..97f6b4a04 100644 --- a/apps/api/app/services/demo/shared_document_service.py +++ b/apps/api/app/services/demo/shared_document_service.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -import mimetypes +from pathlib import PurePath from typing import Any from urllib.parse import quote @@ -99,8 +99,9 @@ async def get_catalog(self, db: AsyncSession) -> dict[str, Any]: continue metadata: dict[str, Any] = revision.document_metadata or {} mimeType: str = ( - mimetypes.guess_type(document.source_file_name or document.title)[0] - or "application/octet-stream" + JobFileStorage.get_content_type( + PurePath(document.source_file_name or document.title).suffix + ) ) source: dict[str, Any] = { "demo_source_id": document.demo_source_id, @@ -206,7 +207,7 @@ async def get_media_url( ) if not key: raise NotFoundException(resource="Demo original", resource_id=source_id) - mimeType: str = mimetypes.guess_type(fileName)[0] or "application/octet-stream" + mimeType: str = JobFileStorage.get_content_type(PurePath(fileName).suffix) # Redirects let the storage service handle Range without proxying large # original files through an API worker. The GET signature overrides MIME. return await asyncio.to_thread( diff --git a/apps/api/tests/contract/test_shared_demo_corpus_contract.py b/apps/api/tests/contract/test_shared_demo_corpus_contract.py index ef783f84e..3c4ce43ba 100644 --- a/apps/api/tests/contract/test_shared_demo_corpus_contract.py +++ b/apps/api/tests/contract/test_shared_demo_corpus_contract.py @@ -464,16 +464,43 @@ async def test_demo_agent_tools_pin_history_and_private_isolation( @pytest.mark.asyncio +@pytest.mark.parametrize( + ("file_name", "original_mime"), + [ + ("original.pdf", "application/pdf"), + ( + "original.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ), + ( + "original.DOCX", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ), + ], +) async def test_demo_revision_media_validation_and_job_retention( - developer_api_client_factory: ClientFactory, monkeypatch: pytest.MonkeyPatch + developer_api_client_factory: ClientFactory, + monkeypatch: pytest.MonkeyPatch, + file_name: str, + original_mime: str, ) -> None: async with developer_api_client_factory() as client: job = await create_demo( - client, source_id="media-demo", file_name="original.pdf" + client, source_id="media-demo", file_name=file_name ) revision = await publish_demo(job, has_page=True) + import mimetypes + from pathlib import PurePath from shared.services.storage.job_file_storage import JobFileStorage + # Slim runtime images need not include the operating system MIME registry. + mimetypes.init() + monkeypatch.delitem(mimetypes.types_map, PurePath(file_name).suffix.lower(), raising=False) + catalog = (await client.get("/api/v1/demo/catalog")).json() + source = next(item for item in catalog["sources"] if item["demo_source_id"] == "media-demo") + assert source["mime_type"] == original_mime + assert source["original_file"]["mime_type"] == original_mime + calls: list[dict[str, Any]] = [] def sign(key: str, **arguments: Any) -> str: @@ -487,7 +514,7 @@ class SignedStorage: JobFileStorage, "storage_adapter", property(lambda self: SignedStorage()) ) for path, mime in ( - ("original", "application/pdf"), + ("original", original_mime), ("assets/images/rocket.png", "image/png"), ("assets/tables/launch.html", "text/html"), ):