from __future__ import annotations import io import struct import zipfile from dataclasses import asdict import pytest from app.services.document_ingestion import ( BlockKind, ChunkingConfig, CloudTextPolicy, DocumentFormat, DocumentIngestionError, IngestionErrorCode, IngestionStatus, ingest_document, ) DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" CONTENT_TYPES = b""" """ def _docx_bytes(document_xml: bytes, extra: dict[str, bytes] | None = None) -> bytes: output = io.BytesIO() with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as package: package.writestr("[Content_Types].xml", CONTENT_TYPES) package.writestr("word/document.xml", document_xml) for name, value in (extra or {}).items(): package.writestr(name, value) return output.getvalue() def _word_document(body: str) -> bytes: return f""" {body} """.encode() def _mark_zip_encrypted(value: bytes) -> bytes: mutated = bytearray(value) local = mutated.find(b"PK\x03\x04") central = mutated.find(b"PK\x01\x02") assert local >= 0 and central >= 0 local_flags = struct.unpack_from(" DocumentIngestionError: with pytest.raises(DocumentIngestionError) as captured: ingest_document( filename=filename, declared_mime_type=mime, content=content, max_upload_bytes=max_upload_bytes, ) assert captured.value.code is code return captured.value def test_utf8_markdown_preserves_heading_page_line_and_chunk_anchors() -> None: content = ( "# 区域地质\n\n第一段包含铜矿化描述。\n\n" "## 蚀变特征\n\n钾化与绢英岩化可作为演示找矿标志。\f" "## 第二页\n\n第二页保留逻辑页号。" ).encode() artifact = ingest_document( filename="synthetic.md", declared_mime_type="text/markdown; charset=utf-8", content=content, chunking=ChunkingConfig(target_tokens=24, max_tokens=40, overlap_tokens=4), ) assert artifact.status is IngestionStatus.READY_FOR_LOCAL_REVIEW assert artifact.document_format is DocumentFormat.MARKDOWN assert [page.page_number for page in artifact.pages] == [1, 2] headings = [block for block in artifact.blocks if block.kind is BlockKind.HEADING] assert [block.section_path for block in headings] == [ ("区域地质",), ("区域地质", "蚀变特征"), ("区域地质", "第二页"), ] assert artifact.chunks assert all(chunk.anchor.block_ids for chunk in artifact.chunks) assert all(chunk.anchor.line_start <= chunk.anchor.line_end for chunk in artifact.chunks) assert artifact.chunks[-1].anchor.page_end == 2 assert artifact.manifest is not None assert len(artifact.manifest.items) == len(artifact.chunks) def test_utf16_text_is_supported_and_form_feed_creates_logical_pages() -> None: content = "第一页面。\f第二页面。".encode("utf-16") artifact = ingest_document( filename="synthetic.txt", declared_mime_type="text/plain", content=content, ) assert [page.page_number for page in artifact.pages] == [1, 2] assert "第一页面" in artifact.pages[0].text assert "第二页面" in artifact.pages[1].text def test_markdown_heading_inside_fence_is_not_promoted() -> None: artifact = ingest_document( filename="synthetic.md", declared_mime_type="text/markdown", content=b"# Heading\n\n```text\n# not-a-heading\n```\n", ) headings = [block.text for block in artifact.blocks if block.kind is BlockKind.HEADING] assert headings == ["Heading"] assert "# not-a-heading" in artifact.blocks[-1].text def test_docx_parses_heading_paragraph_and_table_without_third_party_library() -> None: document = _word_document( """ 矿床概况 这是虚构的铜矿化描述。 样品号 Cu_% """ ) artifact = ingest_document( filename="synthetic.docx", declared_mime_type=DOCX_MIME, content=_docx_bytes(document), ) assert artifact.document_format is DocumentFormat.DOCX assert [block.kind for block in artifact.blocks] == [ BlockKind.HEADING, BlockKind.PARAGRAPH, BlockKind.TABLE_ROW, ] assert artifact.blocks[1].section_path == ("矿床概况",) assert artifact.blocks[2].text == "样品号\tCu_%" assert artifact.pages[0].page_number is None @pytest.mark.parametrize( ("filename", "mime", "content", "code"), [ ("empty.txt", "text/plain", b"", IngestionErrorCode.EMPTY_FILE), ( "spoof.txt", "application/pdf", b"plain text", IngestionErrorCode.MIME_EXTENSION_MISMATCH, ), ( "spoof.txt", "text/plain", b"%PDF-1.7\n", IngestionErrorCode.MIME_CONTENT_MISMATCH, ), ( "invalid.txt", "text/plain", b"\x81\x82\x83", IngestionErrorCode.INVALID_TEXT_ENCODING, ), ( "archive.zip", "application/zip", b"PK\x03\x04", IngestionErrorCode.UNSUPPORTED_MEDIA_TYPE, ), ], ) def test_upload_envelope_and_encoding_fail_closed( filename: str, mime: str, content: bytes, code: IngestionErrorCode, ) -> None: _assert_error(code, filename=filename, mime=mime, content=content) def test_upload_size_is_checked_before_parsing() -> None: _assert_error( IngestionErrorCode.FILE_TOO_LARGE, filename="large.txt", mime="text/plain", content=b"12345", max_upload_bytes=4, ) def test_docx_rejects_path_traversal_and_active_content() -> None: document = _word_document("safe") traversal = _docx_bytes(document, {"../outside.txt": b"bad"}) active = _docx_bytes(document, {"word/vbaProject.bin": b"macro"}) _assert_error( IngestionErrorCode.DOCX_PATH_TRAVERSAL, filename="unsafe.docx", mime=DOCX_MIME, content=traversal, ) _assert_error( IngestionErrorCode.DOCX_ACTIVE_CONTENT, filename="active.docx", mime=DOCX_MIME, content=active, ) def test_docx_rejects_encryption_flag_and_compression_bomb() -> None: document = _word_document("safe") encrypted = _mark_zip_encrypted(_docx_bytes(document)) bomb = _docx_bytes(document, {"word/media/repeated.bin": b"A" * 2_000_000}) _assert_error( IngestionErrorCode.DOCX_ENCRYPTED, filename="encrypted.docx", mime=DOCX_MIME, content=encrypted, ) _assert_error( IngestionErrorCode.DOCX_PACKAGE_LIMIT, filename="bomb.docx", mime=DOCX_MIME, content=bomb, max_upload_bytes=3_000_000, ) def test_docx_rejects_doctype_and_missing_required_parts() -> None: unsafe_xml = b"""]> &secret; """ incomplete = io.BytesIO() with zipfile.ZipFile(incomplete, "w") as package: package.writestr("[Content_Types].xml", CONTENT_TYPES) _assert_error( IngestionErrorCode.DOCX_UNSAFE_XML, filename="unsafe.docx", mime=DOCX_MIME, content=_docx_bytes(unsafe_xml), ) _assert_error( IngestionErrorCode.INVALID_DOCX_PACKAGE, filename="incomplete.docx", mime=DOCX_MIME, content=incomplete.getvalue(), ) def test_pdf_is_routed_fail_closed_without_text_or_spatial_claims() -> None: artifact = ingest_document( filename="synthetic.pdf", declared_mime_type="application/pdf", content=b"%PDF-1.7\nsynthetic bytes only", ) assert artifact.status is IngestionStatus.OCR_REQUIRED assert artifact.pages == () assert artifact.blocks == () assert artifact.chunks == () assert artifact.manifest is None assert "NO_MAP_OR_SPATIAL_UNDERSTANDING" in artifact.limitations def test_chunking_is_bounded_overlapping_and_deterministic() -> None: content = (" ".join(f"term{index}" for index in range(900))).encode() config = ChunkingConfig(target_tokens=512, max_tokens=800, overlap_tokens=64) first = ingest_document( filename="long.txt", declared_mime_type="text/plain", content=content, chunking=config, ) second = ingest_document( filename="renamed.txt", declared_mime_type="text/plain", content=content, chunking=config, ) assert [chunk.token_count for chunk in first.chunks] == [512, 452] assert all(chunk.token_count <= config.max_tokens for chunk in first.chunks) assert first == second assert first.manifest is not None and second.manifest is not None assert first.manifest.manifest_sha256 == second.manifest.manifest_sha256 first_tail = first.chunks[0].display_text.split()[-64:] second_head = first.chunks[1].display_text.split()[:64] assert first_tail == second_head reconstructed = ( first.chunks[0].display_text.split() + first.chunks[1].display_text.split()[config.overlap_tokens :] ) assert reconstructed == content.decode().split() def test_cloud_and_embedding_text_are_separate_and_hash_bound() -> None: artifact = ingest_document( filename="redacted.md", declared_mime_type="text/markdown", content="# 钻孔\n\n项目代号 DEMO-42 位于虚构地区。".encode(), cloud_policy=CloudTextPolicy( policy_id="synthetic-redaction-v1", redact_literals=("DEMO-42",), ), ) chunk = artifact.chunks[0] assert "DEMO-42" in chunk.display_text assert "DEMO-42" not in chunk.cloud_text assert "[REDACTED]" in chunk.cloud_text assert chunk.embedding_text == chunk.embedding_prefix + chunk.cloud_text assert chunk.display_text_sha256 != chunk.cloud_text_sha256 assert chunk.cloud_text_sha256 != chunk.embedding_text_sha256 assert artifact.manifest is not None assert artifact.manifest.items[0].cloud_text_sha256 == chunk.cloud_text_sha256 def test_credential_shapes_never_enter_errors_or_artifacts() -> None: secret = "sk-" + "A" * 24 error = _assert_error( IngestionErrorCode.SENSITIVE_CONTENT_DETECTED, filename="secret.txt", mime="text/plain", content=f"credential={secret}".encode(), ) assert secret not in str(error) assert secret not in repr(error) def test_manifest_and_citation_anchor_change_when_source_changes() -> None: first = ingest_document( filename="anchor.md", declared_mime_type="text/markdown", content="# 标题\n\n第一版证据。".encode(), ) second = ingest_document( filename="anchor.md", declared_mime_type="text/markdown", content="# 标题\n\n第二版证据。".encode(), ) assert first.manifest is not None and second.manifest is not None assert first.raw_sha256 != second.raw_sha256 assert first.chunks[0].anchor.anchor_id != second.chunks[0].anchor.anchor_id assert first.manifest.manifest_sha256 != second.manifest.manifest_sha256 assert first.chunks[0].anchor.normalized_text_sha256 == first.normalized_text_sha256 assert first.chunks[0].anchor.char_start < first.chunks[0].anchor.char_end assert asdict(first)["manifest"]["manifest_sha256"] == first.manifest.manifest_sha256