"""Tests for omnigent.runtime.content_resolver.""" from __future__ import annotations import base64 from dataclasses import dataclass from typing import Any import pytest from omnigent.entities import ConversationItem, StoredFile from omnigent.entities.conversation import ( FunctionCallData, MessageData, ) from omnigent.runtime.content_resolver import resolve_content_references # ── Fake stores ────────────────────────────────────────────────────── @dataclass class FakeFileStore: """ In-memory FileStore that returns pre-configured StoredFile objects by file_id. :param files: Mapping of file_id to StoredFile. """ files: dict[str, StoredFile] def get(self, file_id: str) -> StoredFile | None: """ Return the StoredFile for *file_id*, or ``None``. :param file_id: The file identifier to look up. :returns: The matching StoredFile or None. """ return self.files.get(file_id) @dataclass class FakeArtifactStore: """ In-memory ArtifactStore that returns pre-configured binary blobs by key. :param blobs: Mapping of artifact key to binary content. """ blobs: dict[str, bytes] def get(self, key: str) -> bytes: """ Return binary content for *key*. :param key: The artifact key to look up. :returns: The binary content. :raises KeyError: If no blob exists for the key. """ return self.blobs[key] # ── Helpers ────────────────────────────────────────────────────────── def _make_conversation_item( content: list[dict[str, Any]], *, role: str = "user", item_id: str = "msg_001", response_id: str = "resp_001", ) -> ConversationItem: """ Build a message ConversationItem with the given content blocks. :param content: Content block dicts, e.g. ``[{"type": "input_text", "text": "Hello"}]``. :param role: Message role, ``"user"`` or ``"assistant"``. :param item_id: Store-assigned item ID. :param response_id: The response/task ID. :returns: A ConversationItem with type ``"message"``. """ data = MessageData( role=role, content=content, # assistant messages require an agent name. agent="test-agent" if role == "assistant" else None, ) return ConversationItem( id=item_id, type="message", status="completed", response_id=response_id, created_at=1000, data=data, ) def _make_function_call_item( *, item_id: str = "fc_001", response_id: str = "resp_001", ) -> ConversationItem: """ Build a function_call ConversationItem. :param item_id: Store-assigned item ID. :param response_id: The response/task ID. :returns: A ConversationItem with type ``"function_call"``. """ return ConversationItem( id=item_id, type="function_call", status="completed", response_id=response_id, created_at=1000, data=FunctionCallData( agent="test-agent", call_id="call_001", name="grep", arguments="{}", ), ) PNG_BYTES = b"\x89PNG\r\n\x1a\n fake png content" PDF_BYTES = b"%PDF-1.4 fake pdf content" @pytest.fixture() def file_store() -> FakeFileStore: """ FileStore with two pre-configured files: an image and a PDF. :returns: A FakeFileStore with ``file_img`` and ``file_pdf``. """ return FakeFileStore( files={ "file_img": StoredFile( id="file_img", created_at=1000, filename="photo.png", bytes=len(PNG_BYTES), content_type="image/png", ), "file_pdf": StoredFile( id="file_pdf", created_at=1000, filename="report.pdf", bytes=len(PDF_BYTES), content_type="application/pdf", ), } ) @pytest.fixture() def artifact_store() -> FakeArtifactStore: """ ArtifactStore with binary content for the image and PDF files. :returns: A FakeArtifactStore with blobs for ``file_img`` and ``file_pdf``. """ return FakeArtifactStore( blobs={ "file_img": PNG_BYTES, "file_pdf": PDF_BYTES, } ) # ── Tests ──────────────────────────────────────────────────────────── def test_text_only_message_passes_through( file_store: FakeFileStore, artifact_store: FakeArtifactStore, ) -> None: """ Messages with only text blocks should pass through unchanged (no copy, no modification). """ item = _make_conversation_item([{"type": "input_text", "text": "Hello"}]) result = resolve_content_references( [item], file_store, artifact_store, # type: ignore[arg-type] ) # Exactly one item returned, same object (no copy needed). # Failure would mean text-only messages are unnecessarily copied. assert len(result) == 1 assert result[0] is item def test_input_image_file_id_resolved_to_data_uri( file_store: FakeFileStore, artifact_store: FakeArtifactStore, ) -> None: """ input_image with file_id must be resolved to a data: URI in image_url, with file_id removed from the block. """ item = _make_conversation_item( [ {"type": "input_text", "text": "What's in this image?"}, { "type": "input_image", "file_id": "file_img", "detail": "auto", }, ] ) result = resolve_content_references( [item], file_store, artifact_store, # type: ignore[arg-type] ) # Result is a copy — original must not be modified. # Failure would mean in-place mutation corrupts conversation store. assert result[0] is not item assert isinstance(result[0].data, MessageData) blocks = result[0].data.content # Text block passes through unchanged. assert blocks[0] == {"type": "input_text", "text": "What's in this image?"} # Image block: file_id replaced with data: URI. img_block = blocks[1] expected_b64 = base64.b64encode(PNG_BYTES).decode("ascii") # file_id must be removed — it's a local reference the LLM can't use. # Failure would mean the LLM receives a meaningless file_id. assert "file_id" not in img_block # image_url must be a data: URI with the correct content type. # Failure would mean the LLM receives an invalid image reference. assert img_block["image_url"] == (f"data:image/png;base64,{expected_b64}") # detail field must be preserved — it controls provider image resolution. # Failure would mean the client's detail preference is lost. assert img_block["detail"] == "auto" # Block type must be preserved for downstream translation layers. assert img_block["type"] == "input_image" def test_input_file_file_id_resolved_to_file_data( file_store: FakeFileStore, artifact_store: FakeArtifactStore, ) -> None: """ input_file with file_id must be resolved to file_data (base64), with content_type from file store metadata. """ item = _make_conversation_item( [ {"type": "input_text", "text": "Summarize"}, { "type": "input_file", "file_id": "file_pdf", "filename": "report.pdf", }, ] ) result = resolve_content_references( [item], file_store, artifact_store, # type: ignore[arg-type] ) assert result[0] is not item assert isinstance(result[0].data, MessageData) file_block = result[0].data.content[1] expected_b64 = base64.b64encode(PDF_BYTES).decode("ascii") # file_id must be removed. assert "file_id" not in file_block # file_data must be a data: URI with the content_type and base64. # Failure would mean the LLM receives no file content. assert file_block["file_data"] == f"data:application/pdf;base64,{expected_b64}" # content_type is embedded in the data: URI, not a separate field. assert "content_type" not in file_block # filename must be preserved from the original block. assert file_block["filename"] == "report.pdf" assert file_block["type"] == "input_file" def test_image_url_passes_through_unchanged( file_store: FakeFileStore, artifact_store: FakeArtifactStore, ) -> None: """ input_image with image_url (no file_id) must pass through unchanged — URLs are never fetched server-side (SSRF protection). """ item = _make_conversation_item( [ { "type": "input_image", "image_url": "https://example.com/photo.png", "detail": "high", }, ] ) result = resolve_content_references( [item], file_store, artifact_store, # type: ignore[arg-type] ) # No file_id in any block — original item returned as-is. # Failure would mean URL-only messages are unnecessarily copied. assert result[0] is item def test_inline_file_data_passes_through_unchanged( file_store: FakeFileStore, artifact_store: FakeArtifactStore, ) -> None: """ input_file with file_data (no file_id) must pass through unchanged — content is already inline. """ item = _make_conversation_item( [ { "type": "input_file", "file_data": "JVBERi0xLjQK", "filename": "report.pdf", }, ] ) result = resolve_content_references( [item], file_store, artifact_store, # type: ignore[arg-type] ) # No file_id — original item returned. assert result[0] is item def test_non_message_items_pass_through( file_store: FakeFileStore, artifact_store: FakeArtifactStore, ) -> None: """ Non-message items (function_call, function_call_output) must pass through unchanged — only message content blocks are scanned. """ fc_item = _make_function_call_item() result = resolve_content_references( [fc_item], file_store, artifact_store, # type: ignore[arg-type] ) # function_call items have no content blocks to resolve. # Failure would mean non-message items are incorrectly processed. assert len(result) == 1 assert result[0] is fc_item def test_missing_file_id_raises_value_error( file_store: FakeFileStore, artifact_store: FakeArtifactStore, ) -> None: """ Referencing a file_id that doesn't exist in the file store must raise ValueError — fail loud, no silent dropping. """ item = _make_conversation_item( [ { "type": "input_image", "file_id": "file_nonexistent", }, ] ) with pytest.raises(ValueError, match="file_nonexistent"): resolve_content_references( [item], file_store, artifact_store, # type: ignore[arg-type] ) def test_original_item_not_mutated( file_store: FakeFileStore, artifact_store: FakeArtifactStore, ) -> None: """ The original ConversationItem must not be mutated — the resolver returns copies for modified items. """ original_content = [ {"type": "input_image", "file_id": "file_img"}, ] item = _make_conversation_item(original_content) resolve_content_references( [item], file_store, artifact_store, # type: ignore[arg-type] ) # Original content must still contain file_id — not mutated. # Failure would mean the resolver modifies conversation store data. assert isinstance(item.data, MessageData) assert item.data.content[0]["file_id"] == "file_img" assert "image_url" not in item.data.content[0] def test_unknown_block_type_with_file_id_resolved( file_store: FakeFileStore, artifact_store: FakeArtifactStore, ) -> None: """ An unrecognized block type (e.g. input_audio) with file_id must still have its file_id resolved — the resolver resolves file_id on any block type, not just known ones. """ item = _make_conversation_item( [ { "type": "input_audio", "file_id": "file_img", }, ] ) result = resolve_content_references( [item], file_store, artifact_store, # type: ignore[arg-type] ) assert isinstance(result[0].data, MessageData) block = result[0].data.content[0] # file_id resolved even for unknown type. # Failure would mean new content types can't use file_id. assert "file_id" not in block # Unknown types get file_data (not image_url, which is # only for input_image). assert "file_data" in block assert block["type"] == "input_audio" def test_mixed_items_preserves_order( file_store: FakeFileStore, artifact_store: FakeArtifactStore, ) -> None: """ A mixed list of message and non-message items must preserve chronological order after resolution. """ items = [ _make_conversation_item( [{"type": "input_text", "text": "first"}], item_id="msg_001", ), _make_conversation_item( [{"type": "input_image", "file_id": "file_img"}], item_id="msg_002", ), _make_function_call_item(item_id="fc_001"), _make_conversation_item( [{"type": "input_text", "text": "third"}], item_id="msg_003", ), ] result = resolve_content_references( items, file_store, artifact_store, # type: ignore[arg-type] ) # Order must be preserved: msg, msg(resolved), fc, msg. # Failure would mean resolution reorders conversation history. assert len(result) == 4 assert result[0].id == "msg_001" assert result[1].id == "msg_002" assert result[2].id == "fc_001" assert result[3].id == "msg_003" # Only msg_002 should be a copy (it had file_id). assert result[0] is items[0] assert result[1] is not items[1] assert result[2] is items[2] assert result[3] is items[3] @pytest.mark.parametrize( ("block_type", "expected_field"), [ pytest.param( "input_image", "image_url", id="input_image_gets_data_uri", ), pytest.param( "input_file", "file_data", id="input_file_gets_file_data", ), ], ) def test_resolution_field_varies_by_block_type( block_type: str, expected_field: str, file_store: FakeFileStore, artifact_store: FakeArtifactStore, ) -> None: """ input_image blocks get image_url (data: URI), while input_file blocks get file_data (data: URI). The resolution target field depends on block type. """ item = _make_conversation_item( [ {"type": block_type, "file_id": "file_img"}, ] ) result = resolve_content_references( [item], file_store, artifact_store, # type: ignore[arg-type] ) assert isinstance(result[0].data, MessageData) block = result[0].data.content[0] # The expected field must be present after resolution. # Failure would mean the wrong inline format is used for this type. assert expected_field in block assert "file_id" not in block # ── Cache tests ───────────────────────────────────────────────────── def test_cache_avoids_redundant_artifact_fetch( file_store: FakeFileStore, artifact_store: FakeArtifactStore, ) -> None: """ When a cache dict is provided, the second resolution of the same file_id must use the cached base64 instead of re-fetching from the artifact store. """ cache: dict[str, str] = {} item = _make_conversation_item([{"type": "input_image", "file_id": "file_img"}]) # First call — populates cache. resolve_content_references( [item], file_store, artifact_store, # type: ignore[arg-type] cache, ) # Cache should now contain the file_id. assert "file_img" in cache expected_b64 = base64.b64encode(PNG_BYTES).decode("ascii") # Cached value is the raw base64, not a data: URI. assert cache["file_img"] == expected_b64 # Sabotage the artifact store — if the cache is working, the # resolver won't call artifact_store.get() again. artifact_store.blobs = {} result2 = resolve_content_references( [item], file_store, artifact_store, # type: ignore[arg-type] cache, ) # Second call should still succeed using cached value. assert isinstance(result2[0].data, MessageData) img_block = result2[0].data.content[0] assert img_block["image_url"] == f"data:image/png;base64,{expected_b64}" def test_cache_none_disables_caching( file_store: FakeFileStore, artifact_store: FakeArtifactStore, ) -> None: """ Passing ``cache=None`` (the default) must still resolve correctly — caching is optional. """ item = _make_conversation_item([{"type": "input_image", "file_id": "file_img"}]) result = resolve_content_references( [item], file_store, artifact_store, # type: ignore[arg-type] # Explicit None — no cache. None, ) assert isinstance(result[0].data, MessageData) img_block = result[0].data.content[0] expected_b64 = base64.b64encode(PNG_BYTES).decode("ascii") assert img_block["image_url"] == f"data:image/png;base64,{expected_b64}" # ── Error handling tests ──────────────────────────────────────────── def test_assistant_message_file_id_resolved( file_store: FakeFileStore, artifact_store: FakeArtifactStore, ) -> None: """ An assistant message in conversation history with file_id must have that reference resolved — the content resolver scans all message roles, not just user messages. Catches bugs where role-based filtering causes assistant messages (e.g. from a prior turn's multimodal output) to retain unresolved file_id references. """ item = _make_conversation_item( [ {"type": "input_text", "text": "Here is the analysis"}, {"type": "input_file", "file_id": "file_pdf", "filename": "report.pdf"}, ], role="assistant", ) result = resolve_content_references( [item], file_store, artifact_store, # type: ignore[arg-type] ) # Must be a copy — assistant message was modified. assert result[0] is not item assert isinstance(result[0].data, MessageData) file_block = result[0].data.content[1] expected_b64 = base64.b64encode(PDF_BYTES).decode("ascii") # file_id must be removed. assert "file_id" not in file_block # file_data must be a data: URI. assert file_block["file_data"] == f"data:application/pdf;base64,{expected_b64}" assert file_block["filename"] == "report.pdf" assert file_block["type"] == "input_file" def test_deleted_file_raises_clear_error( file_store: FakeFileStore, artifact_store: FakeArtifactStore, ) -> None: """ When a file is deleted between request validation and agent loop execution, the error message must clearly indicate the file was deleted — not a generic "not found". """ item = _make_conversation_item([{"type": "input_image", "file_id": "file_nonexistent"}]) with pytest.raises(ValueError, match="no longer exists"): resolve_content_references( [item], file_store, artifact_store, # type: ignore[arg-type] ) # ── _resolve_content_type tests ─────────────────────────────────────── def test_resolve_content_type_uses_stored_type() -> None: """Stored content_type is used when it's not the generic fallback.""" from omnigent.runtime.content_resolver import _resolve_content_type assert _resolve_content_type("text/markdown", "test.md") == "text/markdown" assert _resolve_content_type("application/pdf", "report.pdf") == "application/pdf" def test_resolve_content_type_ignores_octet_stream() -> None: """application/octet-stream is treated as unresolved — falls through to filename.""" from omnigent.runtime.content_resolver import _resolve_content_type result = _resolve_content_type("application/octet-stream", "readme.md") assert result == "text/markdown", f"Expected text/markdown, got {result}" def test_resolve_content_type_falls_back_to_extra_map() -> None: """Extensions missing from mimetypes use the _EXTRA_MIME_TYPES fallback.""" from omnigent.runtime.content_resolver import _resolve_content_type # These extensions are NOT in Python 3.10's mimetypes module # (except .ts, which mimetypes maps to video/mp2t). assert _resolve_content_type(None, "config.yaml") == "text/yaml" assert _resolve_content_type(None, "config.yml") == "text/yaml" assert _resolve_content_type(None, "main.go") == "text/x-go" # .ts is mapped by mimetypes to video/mp2t (MPEG transport stream) # and .rs to application/rls-services+xml — both wrong for source # code. Our _EXTRA_MIME_TYPES map takes priority. assert _resolve_content_type(None, "app.ts") == "text/typescript" assert _resolve_content_type(None, "app.tsx") == "text/typescript" assert _resolve_content_type(None, "lib.rs") == "text/x-rust" assert _resolve_content_type(None, "main.swift") == "text/x-swift" assert _resolve_content_type(None, "data.jsonl") == "application/jsonl" assert _resolve_content_type(None, "notebook.ipynb") == "application/x-ipynb+json" def test_resolve_content_type_no_filename_uses_fallback() -> None: """When neither stored type nor filename is available, falls back to octet-stream.""" from omnigent.runtime.content_resolver import _resolve_content_type assert _resolve_content_type(None, None) == "application/octet-stream" def test_resolve_content_type_known_extension_uses_mimetypes() -> None: """Standard extensions (e.g. .pdf, .html) use mimetypes.guess_type.""" from omnigent.runtime.content_resolver import _resolve_content_type # .pdf and .html are universally known by mimetypes. result = _resolve_content_type(None, "report.pdf") assert result == "application/pdf" result = _resolve_content_type(None, "page.html") assert result == "text/html" def test_resolve_content_type_case_insensitive() -> None: """Extension matching is case-insensitive.""" from omnigent.runtime.content_resolver import _resolve_content_type assert _resolve_content_type(None, "README.MD") == "text/markdown" assert _resolve_content_type(None, "config.YAML") == "text/yaml" def test_content_resolver_uses_filename_for_mime( file_store: FakeFileStore, artifact_store: FakeArtifactStore, ) -> None: """ End-to-end: when stored content_type is octet-stream, the resolver uses the filename to determine the correct MIME type in the data URI. """ md_content = b"# Hello\n\nMarkdown file." file_store.files["file_md"] = StoredFile( id="file_md", filename="readme.md", bytes=len(md_content), content_type="application/octet-stream", created_at=1000, ) artifact_store.blobs["file_md"] = md_content item = _make_conversation_item( [{"type": "input_file", "file_id": "file_md", "filename": "readme.md"}] ) resolved = resolve_content_references( [item], file_store, artifact_store, # type: ignore[arg-type] ) resolved_data = resolved[0].data assert isinstance(resolved_data, MessageData) file_block = resolved_data.content[0] # Must be text/markdown, NOT application/octet-stream. assert file_block["file_data"].startswith("data:text/markdown;base64,"), ( f"Expected text/markdown data URI, got: {file_block['file_data'][:60]}" ) # ── _safe_file_data_mime + end-to-end downgrade behaviour ──────────── @pytest.mark.parametrize( "passthrough_mime", [ "application/pdf", "text/plain", "text/markdown", "text/html", "text/csv", "application/json", "text/javascript", "application/javascript", "text/x-python", ], ) def test_safe_file_data_mime_passthrough(passthrough_mime: str) -> None: """MIMEs on the passthrough list are returned unchanged. Pinned to the explicit list because adding/removing entries changes what the OpenAI Responses API will accept on file_data; that's a deliberate decision, not an accidental one. """ from omnigent.runtime.content_resolver import _safe_file_data_mime assert _safe_file_data_mime(passthrough_mime) == passthrough_mime @pytest.mark.parametrize( "rejected_mime", [ # The exact MIME the user reported (text/yaml triggers a 400 # "unsupported MIME type 'text/yaml'" from the Responses API). "text/yaml", # Other text/* entries from _EXTRA_MIME_TYPES that the # Responses API rejects in the same way. "text/typescript", "text/x-rust", "text/x-go", "text/x-swift", "text/x-kotlin", "text/x-haskell", "text/x-julia", # JSONL / ndjson / ipynb — application/x-* variants the # Responses API treats the same way as exotic text MIMEs. "application/jsonl", "application/x-ndjson", "application/x-ipynb+json", ], ) def test_safe_file_data_mime_collapses_unrecognised_text(rejected_mime: str) -> None: """Text-like MIMEs the Responses API rejects collapse to text/plain. The base64 payload doesn't change shape; only the data: URI prefix shifts so the provider's MIME validator accepts the block. The block's ``filename`` field still carries the original extension for the model. """ from omnigent.runtime.content_resolver import _safe_file_data_mime assert _safe_file_data_mime(rejected_mime) == "text/plain" def test_safe_file_data_mime_leaves_non_text_alone() -> None: """Non-text MIMEs we don't have an opinion on pass through. We don't ship a complete list of non-text formats, so guessing "this image/audio/binary type is unsafe" would mislead providers. Pass them through and let the provider validate. """ from omnigent.runtime.content_resolver import _safe_file_data_mime assert _safe_file_data_mime("image/png") == "image/png" assert _safe_file_data_mime("audio/mpeg") == "audio/mpeg" assert _safe_file_data_mime("application/zip") == "application/zip" def test_resolve_yaml_file_uses_text_plain_in_data_uri( file_store: FakeFileStore, artifact_store: FakeArtifactStore, ) -> None: """End-to-end: pasting a YAML file produces ``data:text/plain;...``. Regression check for the user-reported "Invalid file data ... got unsupported MIME type 'text/yaml'" 400 from the Responses API. Without :func:`_safe_file_data_mime` the data URI would carry ``text/yaml`` and the provider would reject every turn that re-sent the conversation history. """ yaml_content = b"name: example\nvalue: 42\n" file_store.files["file_yaml"] = StoredFile( id="file_yaml", filename="config.yaml", bytes=len(yaml_content), content_type="application/octet-stream", created_at=1000, ) artifact_store.blobs["file_yaml"] = yaml_content item = _make_conversation_item( [{"type": "input_file", "file_id": "file_yaml", "filename": "config.yaml"}] ) resolved = resolve_content_references( [item], file_store, artifact_store, # type: ignore[arg-type] ) resolved_data = resolved[0].data assert isinstance(resolved_data, MessageData) file_block = resolved_data.content[0] assert file_block["file_data"].startswith("data:text/plain;base64,"), ( f"Expected text/plain data URI for .yaml, got: {file_block['file_data'][:60]}" ) # Filename is still attached so the model sees the original extension. assert file_block.get("filename") == "config.yaml" # Payload round-trips intact. encoded = file_block["file_data"].split(";base64,", 1)[1] assert base64.b64decode(encoded) == yaml_content def test_resolve_image_file_keeps_specific_mime( file_store: FakeFileStore, artifact_store: FakeArtifactStore, ) -> None: """input_image blocks build image_url; the safe-MIME helper must not run on that path. The Responses API needs the precise image MIME on image_url to pick the right decoder. """ png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 # bare PNG header for the test file_store.files["file_png"] = StoredFile( id="file_png", filename="diagram.png", bytes=len(png_bytes), content_type="image/png", created_at=1000, ) artifact_store.blobs["file_png"] = png_bytes item = _make_conversation_item([{"type": "input_image", "file_id": "file_png"}]) resolved = resolve_content_references( [item], file_store, artifact_store, # type: ignore[arg-type] ) resolved_data = resolved[0].data assert isinstance(resolved_data, MessageData) image_block = resolved_data.content[0] assert image_block["image_url"].startswith("data:image/png;base64,"), ( f"Expected image/png data URI, got: {image_block['image_url'][:60]}" ) # ── Attachment upload limits ────────────────────────────────────────── @pytest.mark.parametrize( ("content_type", "expected_mb"), [ ("image/png", 5), ("image/jpeg", 5), ("image/webp", 5), ("application/pdf", 20), ("text/plain", 10), ("text/markdown", 10), ("text/x-python", 10), ("text/typescript", 10), ("application/json", 10), ("application/x-ipynb+json", 10), ], ) def test_attachment_upload_limit_allowed_types(content_type: str, expected_mb: int) -> None: """Images, PDF, and text-like types get their per-type byte cap.""" from omnigent.runtime.content_resolver import attachment_upload_limit assert attachment_upload_limit(content_type) == expected_mb * 1024 * 1024 @pytest.mark.parametrize( "content_type", [ "application/vnd.openxmlformats-officedocument.presentationml.presentation", # pptx "application/vnd.openxmlformats-officedocument.wordprocessingml.document", # docx "application/vnd.ms-excel", "application/zip", "application/octet-stream", "audio/mpeg", "video/mp4", ], ) def test_attachment_upload_limit_rejects_unsupported_types(content_type: str) -> None: """Office/binary/media types are not uploadable (None ⇒ caller 415s).""" from omnigent.runtime.content_resolver import attachment_upload_limit assert attachment_upload_limit(content_type) is None def test_attachment_upload_limits_are_under_global_ceiling() -> None: """Every per-type limit stays within the global request-size backstop.""" from omnigent.runtime.content_resolver import ( MAX_ATTACHMENT_UPLOAD_BYTES, MAX_IMAGE_UPLOAD_BYTES, MAX_PDF_UPLOAD_BYTES, MAX_TEXT_UPLOAD_BYTES, ) assert MAX_IMAGE_UPLOAD_BYTES <= MAX_ATTACHMENT_UPLOAD_BYTES assert MAX_PDF_UPLOAD_BYTES <= MAX_ATTACHMENT_UPLOAD_BYTES assert MAX_TEXT_UPLOAD_BYTES <= MAX_ATTACHMENT_UPLOAD_BYTES @pytest.mark.parametrize( ("filename", "expected"), [ ("data.csv", "text/csv"), ("notes.txt", "text/plain"), ("main.py", "text/x-python"), ("app.ts", "text/typescript"), ("readme.md", "text/markdown"), ("nb.ipynb", "application/x-ipynb+json"), ], ) def test_attachment_text_type_for_extension_recognised(filename: str, expected: str) -> None: """Known text/code extensions resolve to a text-like MIME (the fallback used when the declared MIME mislabels them as binary).""" from omnigent.runtime.content_resolver import attachment_text_type_for_extension assert attachment_text_type_for_extension(filename) == expected @pytest.mark.parametrize( "filename", ["sheet.xls", "sheet.xlsx", "deck.pptx", "doc.docx", "archive.zip", "blob", None], ) def test_attachment_text_type_for_extension_rejects_binary(filename: str | None) -> None: """Real binaries (and missing/unknown extensions) get no text fallback, so they stay rejected even if the declared MIME is wrong.""" from omnigent.runtime.content_resolver import attachment_text_type_for_extension assert attachment_text_type_for_extension(filename) is None def test_text_code_extensions_resolve_to_allowed_text() -> None: """Every declared text/code extension resolves to a text-like type that has an upload limit — so the route's extension fallback admits it (no 415), regardless of the browser-reported MIME.""" from omnigent.runtime.content_resolver import ( _TEXT_CODE_EXTENSIONS, attachment_text_type_for_extension, attachment_upload_limit, ) for ext in _TEXT_CODE_EXTENSIONS: mime = attachment_text_type_for_extension(f"file{ext}") assert mime is not None, f"{ext} resolved to no text type" assert attachment_upload_limit(mime) is not None, f"{ext} -> {mime} has no limit" def test_client_server_attachment_extension_parity() -> None: """The web client's TEXT_CODE_EXTENSIONS must all be accepted server-side, even when the browser reports a non-text MIME — the parity contract the two share. Guards against the client gate admitting a file the upload route then 415s (the divergence Polly flagged).""" import re from pathlib import Path from omnigent.runtime.content_resolver import ( _resolve_content_type, attachment_text_type_for_extension, attachment_upload_limit, ) ts_path = Path(__file__).resolve().parents[2] / "web" / "src" / "lib" / "attachments.ts" if not ts_path.exists(): pytest.skip("web/src/lib/attachments.ts not present (server-only checkout)") block = ts_path.read_text().split("TEXT_CODE_EXTENSIONS = new Set([")[1].split("]")[0] client_exts = re.findall(r'"(\.[a-z0-9]+)"', block) assert client_exts, "could not parse client TEXT_CODE_EXTENSIONS" # MIMEs a browser/OS might attach to these extensions, including wrong ones. worst_case_mimes = [ "", "application/octet-stream", "video/mp2t", # .ts "application/xml", # .xml "application/x-ruby", # .rb ] def server_accepts(name: str, browser_mime: str) -> bool: content_type = _resolve_content_type(browser_mime, name) limit = attachment_upload_limit(content_type) if limit is None: ext_type = attachment_text_type_for_extension(name) if ext_type is not None: limit = attachment_upload_limit(ext_type) return limit is not None rejected = [ (ext, mime) for ext in client_exts for mime in worst_case_mimes if not server_accepts(f"file{ext}", mime) ] assert not rejected, f"client accepts but server would 415: {rejected}"