708 lines
26 KiB
Python
708 lines
26 KiB
Python
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
# The Office parsers are optional deps (commented-optional in requirements.txt;
|
|
# installed by CI and requirements-dev.txt). On a lean install they are absent,
|
|
# so importorskip these modules to skip this file cleanly instead of aborting
|
|
# collection for the WHOLE suite with a module-level ImportError (repo idiom —
|
|
# see the ~12 other test files that importorskip an optional dependency).
|
|
pytest.importorskip("docx")
|
|
pytest.importorskip("openpyxl")
|
|
pytest.importorskip("pptx")
|
|
|
|
from docx import Document as DocxDocument
|
|
from docx.oxml import OxmlElement
|
|
from docx.oxml.ns import qn
|
|
from openpyxl import Workbook
|
|
from pptx import Presentation
|
|
from pptx.util import Inches
|
|
|
|
import api.office_documents as office_documents
|
|
from api.office_documents import (
|
|
CLAIMED_OFFICE_EXTENSIONS,
|
|
is_claimed_office_path,
|
|
preview_office_document,
|
|
save_office_document,
|
|
)
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
WORKSPACE_JS = (ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
|
|
NODE = shutil.which("node")
|
|
|
|
|
|
def _simple_docx_bytes(*paragraphs: str) -> bytes:
|
|
document = DocxDocument()
|
|
for paragraph in paragraphs or ("",):
|
|
document.add_paragraph(paragraph)
|
|
buffer = io.BytesIO()
|
|
document.save(buffer)
|
|
return buffer.getvalue()
|
|
|
|
|
|
def _rich_docx_bytes() -> bytes:
|
|
document = DocxDocument()
|
|
document.add_paragraph("Lead paragraph")
|
|
table = document.add_table(rows=1, cols=2)
|
|
table.cell(0, 0).text = "left"
|
|
table.cell(0, 1).text = "right"
|
|
document.add_paragraph("Tail paragraph")
|
|
buffer = io.BytesIO()
|
|
document.save(buffer)
|
|
return buffer.getvalue()
|
|
|
|
|
|
def _formatted_docx_bytes() -> bytes:
|
|
document = DocxDocument()
|
|
run = document.add_paragraph().add_run("Styled text")
|
|
run.bold = True
|
|
buffer = io.BytesIO()
|
|
document.save(buffer)
|
|
return buffer.getvalue()
|
|
|
|
|
|
def _paragraph_style_docx_bytes(style: str) -> bytes:
|
|
document = DocxDocument()
|
|
paragraph = document.add_paragraph("Styled paragraph")
|
|
properties = paragraph._p.get_or_add_pPr()
|
|
paragraph_style = OxmlElement("w:pStyle")
|
|
paragraph_style.set(qn("w:val"), style)
|
|
properties.append(paragraph_style)
|
|
buffer = io.BytesIO()
|
|
document.save(buffer)
|
|
return buffer.getvalue()
|
|
|
|
|
|
def _header_docx_bytes() -> bytes:
|
|
document = DocxDocument()
|
|
document.add_paragraph("Body text")
|
|
document.sections[0].header.paragraphs[0].text = "Header text"
|
|
buffer = io.BytesIO()
|
|
document.save(buffer)
|
|
return buffer.getvalue()
|
|
|
|
|
|
def _custom_section_docx_bytes() -> bytes:
|
|
document = DocxDocument()
|
|
document.add_paragraph("Body text")
|
|
document.sections[0].left_margin = Inches(2)
|
|
buffer = io.BytesIO()
|
|
document.save(buffer)
|
|
return buffer.getvalue()
|
|
|
|
|
|
def _simple_xlsx_bytes() -> bytes:
|
|
workbook = Workbook()
|
|
sheet = workbook.active
|
|
sheet.title = "Summary"
|
|
sheet["A1"] = "alpha"
|
|
sheet["B1"] = "beta"
|
|
sheet["A2"] = "gamma"
|
|
buffer = io.BytesIO()
|
|
workbook.save(buffer)
|
|
return buffer.getvalue()
|
|
|
|
|
|
def _simple_pptx_bytes() -> bytes:
|
|
presentation = Presentation()
|
|
slide = presentation.slides.add_slide(presentation.slide_layouts[6])
|
|
box = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
|
|
box.text = "Office preview"
|
|
buffer = io.BytesIO()
|
|
presentation.save(buffer)
|
|
return buffer.getvalue()
|
|
|
|
def _office_zip_bytes(*members: tuple[str, str | bytes]) -> bytes:
|
|
buffer = io.BytesIO()
|
|
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
|
archive.writestr("[Content_Types].xml", "<Types/>")
|
|
for name, payload in members:
|
|
archive.writestr(name, payload)
|
|
return buffer.getvalue()
|
|
|
|
|
|
def _multi_slide_pptx_bytes() -> bytes:
|
|
presentation = Presentation()
|
|
for index in range(2):
|
|
slide = presentation.slides.add_slide(presentation.slide_layouts[6])
|
|
box = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
|
|
box.text = f"Slide {index + 1}"
|
|
buffer = io.BytesIO()
|
|
presentation.save(buffer)
|
|
return buffer.getvalue()
|
|
|
|
|
|
def test_office_registry_claims_docx_xlsx_pptx():
|
|
assert CLAIMED_OFFICE_EXTENSIONS == {".docx", ".xlsx", ".pptx"}
|
|
assert is_claimed_office_path("report.docx")
|
|
assert is_claimed_office_path("budget.xlsx")
|
|
assert is_claimed_office_path("deck.pptx")
|
|
|
|
docx_preview = preview_office_document("report.docx", _simple_docx_bytes("one", "two"))
|
|
xlsx_preview = preview_office_document("budget.xlsx", _simple_xlsx_bytes())
|
|
pptx_preview = preview_office_document("deck.pptx", _simple_pptx_bytes())
|
|
|
|
assert docx_preview["preview_kind"] == "office"
|
|
assert xlsx_preview["preview_kind"] == "office"
|
|
assert pptx_preview["preview_kind"] == "office"
|
|
assert docx_preview["office_format"] == "docx"
|
|
assert xlsx_preview["office_format"] == "xlsx"
|
|
assert pptx_preview["office_format"] == "pptx"
|
|
assert docx_preview["render_mode"] == "code"
|
|
assert xlsx_preview["render_mode"] == "code"
|
|
assert pptx_preview["render_mode"] == "code"
|
|
assert docx_preview["editable"] is True
|
|
assert xlsx_preview["editable"] is False
|
|
assert pptx_preview["editable"] is False
|
|
|
|
|
|
def test_docx_paragraph_projection_round_trips_simple_documents():
|
|
original_bytes = _simple_docx_bytes("alpha", "beta")
|
|
preview = preview_office_document("story.docx", original_bytes)
|
|
|
|
assert preview["editable"] is True
|
|
assert preview["content"] == "alpha\nbeta"
|
|
|
|
saved_preview, saved_bytes = save_office_document("story.docx", original_bytes, "alpha\nbeta\ngamma")
|
|
round_trip = DocxDocument(io.BytesIO(saved_bytes))
|
|
|
|
assert saved_preview["editable"] is True
|
|
assert saved_preview["content"] == "alpha\nbeta\ngamma"
|
|
assert [paragraph.text for paragraph in round_trip.paragraphs] == ["alpha", "beta", "gamma"]
|
|
|
|
|
|
def test_docx_preview_preserves_interleaved_table_order():
|
|
preview = preview_office_document("report.docx", _rich_docx_bytes())
|
|
|
|
assert preview["content"] == "Lead paragraph\nTable 1\nleft\tright\nTail paragraph"
|
|
|
|
|
|
def test_docx_with_explicit_normal_style_stays_editable():
|
|
original_bytes = _paragraph_style_docx_bytes("Normal")
|
|
preview = preview_office_document("styled.docx", original_bytes)
|
|
|
|
assert preview["editable"] is True
|
|
assert preview["content"] == "Styled paragraph"
|
|
|
|
saved_preview, saved_bytes = save_office_document("styled.docx", original_bytes, "Edited paragraph")
|
|
round_trip = DocxDocument(io.BytesIO(saved_bytes))
|
|
|
|
assert saved_preview["editable"] is True
|
|
assert saved_preview["content"] == "Edited paragraph"
|
|
assert [paragraph.text for paragraph in round_trip.paragraphs] == ["Edited paragraph"]
|
|
|
|
|
|
def test_docx_with_non_default_paragraph_style_stays_preview_only():
|
|
preview = preview_office_document("heading.docx", _paragraph_style_docx_bytes("Heading1"))
|
|
|
|
assert preview["editable"] is False
|
|
assert preview.get("edit_blocked_reason")
|
|
|
|
|
|
def test_docx_preview_truncation_disables_editing(monkeypatch):
|
|
monkeypatch.setattr(office_documents, "MAX_DOCX_PREVIEW_BLOCKS", 1)
|
|
monkeypatch.setattr(
|
|
office_documents,
|
|
"_docx_editability",
|
|
lambda _document: pytest.fail("truncated docx preview should not run full editability scan"),
|
|
)
|
|
|
|
preview = preview_office_document("story.docx", _simple_docx_bytes("alpha", "beta"))
|
|
|
|
assert preview["truncated"] is True
|
|
assert preview["editable"] is False
|
|
assert preview["edit_blocked_reason"] == "docx preview exceeds safe limits"
|
|
assert office_documents.OFFICE_PREVIEW_TRUNCATED_NOTICE in preview["content"]
|
|
|
|
|
|
def test_docx_preview_char_budget_disables_editing(monkeypatch):
|
|
monkeypatch.setattr(office_documents, "MAX_OFFICE_PREVIEW_CHARS", 5)
|
|
monkeypatch.setattr(
|
|
office_documents,
|
|
"_docx_editability",
|
|
lambda _document: pytest.fail("char-budget truncation should not run full editability scan"),
|
|
)
|
|
|
|
preview = preview_office_document("story.docx", _simple_docx_bytes("alphabet", "beta"))
|
|
|
|
assert preview["truncated"] is True
|
|
assert preview["editable"] is False
|
|
assert preview["edit_blocked_reason"] == "docx preview exceeds safe limits"
|
|
assert office_documents.OFFICE_PREVIEW_TRUNCATED_NOTICE in preview["content"]
|
|
|
|
@pytest.mark.parametrize(
|
|
("path", "member_name", "loader_attr"),
|
|
[
|
|
("story.docx", "word/document.xml", "_load_docx_document"),
|
|
("budget.xlsx", "xl/sharedStrings.xml", "_load_workbook_reader"),
|
|
("deck.pptx", "ppt/media/image1.png", "_load_presentation_ctor"),
|
|
],
|
|
)
|
|
def test_office_preview_preflight_rejects_zip_bombs_before_loader(
|
|
monkeypatch, path: str, member_name: str, loader_attr: str
|
|
):
|
|
monkeypatch.setattr(office_documents, "MAX_OFFICE_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES", 1024)
|
|
monkeypatch.setattr(
|
|
office_documents,
|
|
loader_attr,
|
|
lambda: lambda *_args, **_kwargs: pytest.fail("office parser should not run after archive preflight rejects the payload"),
|
|
)
|
|
|
|
raw = _office_zip_bytes((member_name, "A" * 4096))
|
|
|
|
with pytest.raises(ValueError, match="safe archive limits"):
|
|
preview_office_document(path, raw)
|
|
|
|
def test_office_preview_preflight_rejects_path_traversal_before_loader(monkeypatch):
|
|
monkeypatch.setattr(
|
|
office_documents,
|
|
"_load_docx_document",
|
|
lambda: lambda *_args, **_kwargs: pytest.fail("docx parser should not run after path validation fails"),
|
|
)
|
|
|
|
raw = _office_zip_bytes(("../word/document.xml", "payload"))
|
|
|
|
with pytest.raises(ValueError, match="safe archive limits"):
|
|
preview_office_document("story.docx", raw)
|
|
|
|
def test_xlsx_preview_preflight_rejects_oversized_shared_strings_before_loader(monkeypatch):
|
|
monkeypatch.setattr(office_documents, "MAX_XLSX_ARCHIVE_SHARED_STRINGS_BYTES", 64)
|
|
monkeypatch.setattr(
|
|
office_documents,
|
|
"_load_workbook_reader",
|
|
lambda: lambda *_args, **_kwargs: pytest.fail("xlsx parser should not run after sharedStrings preflight rejects the payload"),
|
|
)
|
|
|
|
raw = _office_zip_bytes(("xl/sharedStrings.xml", "A" * 256))
|
|
|
|
with pytest.raises(ValueError, match="safe archive limits"):
|
|
preview_office_document("budget.xlsx", raw)
|
|
|
|
def test_xlsx_preview_preflight_measures_actual_bytes_when_zip_claims_zero(monkeypatch):
|
|
class FakeInfo:
|
|
filename = "xl/sharedStrings.xml"
|
|
file_size = 0
|
|
compress_size = 1
|
|
|
|
@staticmethod
|
|
def is_dir():
|
|
return False
|
|
|
|
class FakeArchive:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
@staticmethod
|
|
def infolist():
|
|
return [FakeInfo()]
|
|
|
|
@staticmethod
|
|
def open(_info):
|
|
return io.BytesIO(b"A" * 2048)
|
|
|
|
monkeypatch.setattr(office_documents, "MAX_OFFICE_ARCHIVE_TOTAL_UNCOMPRESSED_BYTES", 1024)
|
|
monkeypatch.setattr(office_documents.zipfile, "ZipFile", lambda *_args, **_kwargs: FakeArchive())
|
|
monkeypatch.setattr(
|
|
office_documents,
|
|
"_load_workbook_reader",
|
|
lambda: lambda *_args, **_kwargs: pytest.fail("xlsx parser should not run when actual inflated bytes exceed the preflight cap"),
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="safe archive limits"):
|
|
preview_office_document("budget.xlsx", b"placeholder")
|
|
|
|
|
|
def _office_state_block() -> str:
|
|
marker = "if(data.preview_kind==='office'){"
|
|
start = WORKSPACE_JS.find(marker)
|
|
assert start >= 0, "office preview state block not found in static/workspace.js"
|
|
depth = 0
|
|
for idx in range(start, len(WORKSPACE_JS)):
|
|
char = WORKSPACE_JS[idx]
|
|
if char == "{":
|
|
depth += 1
|
|
elif char == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return WORKSPACE_JS[start:idx + 1]
|
|
raise AssertionError("could not find balanced office preview state block")
|
|
|
|
|
|
def _run_office_state_block(payload: dict) -> dict:
|
|
js = (
|
|
"const data = " + json.dumps(payload) + ";\n"
|
|
+ "const path = 'report';\n"
|
|
+ "let _previewRawContent = null;\n"
|
|
+ "let _previewRawContentPath = null;\n"
|
|
+ "let _previewServerEditable = null;\n"
|
|
+ "let _previewPreviewKind = '';\n"
|
|
+ "let _previewOfficeFormat = '';\n"
|
|
+ "let _previewSaveRoute = '/api/file/save';\n"
|
|
+ _office_state_block()
|
|
+ "\nconsole.log(JSON.stringify({_previewRawContent,_previewRawContentPath,_previewServerEditable,_previewPreviewKind,_previewOfficeFormat,_previewSaveRoute}));\n"
|
|
)
|
|
result = subprocess.run([NODE, "-e", js], check=True, capture_output=True, text=True, timeout=30)
|
|
return json.loads(result.stdout.strip())
|
|
|
|
|
|
def test_xlsx_stays_preview_only():
|
|
path = "budget.xlsx"
|
|
raw = _simple_xlsx_bytes()
|
|
|
|
preview = preview_office_document(path, raw)
|
|
|
|
assert preview["preview_kind"] == "office"
|
|
assert preview["editable"] is False
|
|
assert preview.get("edit_blocked_reason")
|
|
|
|
with pytest.raises(ValueError):
|
|
save_office_document(path, raw, "edited text")
|
|
|
|
|
|
def test_xlsx_preview_is_bounded(monkeypatch):
|
|
monkeypatch.setattr(office_documents, "MAX_XLSX_PREVIEW_ROWS_PER_SHEET", 1)
|
|
|
|
preview = preview_office_document("budget.xlsx", _simple_xlsx_bytes())
|
|
|
|
assert preview["truncated"] is True
|
|
assert office_documents.OFFICE_PREVIEW_TRUNCATED_NOTICE in preview["content"]
|
|
|
|
|
|
def test_xlsx_preview_stops_when_char_budget_is_exhausted(monkeypatch):
|
|
class FakeSheet:
|
|
title = "Summary"
|
|
max_row = 9_999
|
|
max_column = 9_999
|
|
|
|
def iter_rows(self, *, values_only, max_row, max_col):
|
|
assert values_only is True
|
|
assert max_row == office_documents.MAX_XLSX_PREVIEW_ROWS_PER_SHEET
|
|
assert max_col == office_documents.MAX_XLSX_PREVIEW_CELLS_PER_SHEET
|
|
yield ("x" * 200, "y" * 200)
|
|
pytest.fail("xlsx preview should stop after the preview budget is exhausted")
|
|
|
|
class FakeWorkbook:
|
|
def __init__(self):
|
|
self.worksheets = [FakeSheet()]
|
|
|
|
def close(self):
|
|
return None
|
|
|
|
monkeypatch.setattr(office_documents, "MAX_OFFICE_PREVIEW_CHARS", 32)
|
|
monkeypatch.setattr(office_documents, "_preflight_office_archive", lambda *_args, **_kwargs: None)
|
|
monkeypatch.setattr(office_documents, "_load_workbook_reader", lambda: lambda *_args, **_kwargs: FakeWorkbook())
|
|
|
|
preview = preview_office_document("budget.xlsx", b"placeholder")
|
|
|
|
assert preview["truncated"] is True
|
|
assert office_documents.OFFICE_PREVIEW_TRUNCATED_NOTICE in preview["content"]
|
|
|
|
|
|
def test_pptx_stays_preview_only():
|
|
path = "deck.pptx"
|
|
raw = _simple_pptx_bytes()
|
|
|
|
preview = preview_office_document(path, raw)
|
|
|
|
assert preview["preview_kind"] == "office"
|
|
assert preview["editable"] is False
|
|
assert preview.get("edit_blocked_reason")
|
|
|
|
with pytest.raises(ValueError):
|
|
save_office_document(path, raw, "edited text")
|
|
|
|
|
|
def test_pptx_preview_is_bounded(monkeypatch):
|
|
monkeypatch.setattr(office_documents, "MAX_PPTX_PREVIEW_SLIDES", 1)
|
|
|
|
preview = preview_office_document("deck.pptx", _multi_slide_pptx_bytes())
|
|
|
|
assert preview["truncated"] is True
|
|
assert office_documents.OFFICE_PREVIEW_TRUNCATED_NOTICE in preview["content"]
|
|
|
|
|
|
def test_rich_docx_stays_preview_only():
|
|
path = "rich.docx"
|
|
raw = _rich_docx_bytes()
|
|
|
|
preview = preview_office_document(path, raw)
|
|
|
|
assert preview["preview_kind"] == "office"
|
|
assert preview["editable"] is False
|
|
assert preview.get("edit_blocked_reason")
|
|
|
|
with pytest.raises(ValueError):
|
|
save_office_document(path, raw, "edited text")
|
|
|
|
|
|
def test_formatted_docx_stays_preview_only():
|
|
path = "formatted.docx"
|
|
raw = _formatted_docx_bytes()
|
|
|
|
preview = preview_office_document(path, raw)
|
|
|
|
assert preview["preview_kind"] == "office"
|
|
assert preview["editable"] is False
|
|
assert preview.get("edit_blocked_reason")
|
|
|
|
with pytest.raises(ValueError):
|
|
save_office_document(path, raw, "edited text")
|
|
|
|
def test_header_docx_stays_preview_only():
|
|
path = "headed.docx"
|
|
raw = _header_docx_bytes()
|
|
|
|
preview = preview_office_document(path, raw)
|
|
|
|
assert preview["preview_kind"] == "office"
|
|
assert preview["editable"] is False
|
|
assert preview.get("edit_blocked_reason")
|
|
|
|
with pytest.raises(ValueError):
|
|
save_office_document(path, raw, "edited text")
|
|
|
|
|
|
def test_custom_section_docx_stays_preview_only():
|
|
path = "custom-section.docx"
|
|
raw = _custom_section_docx_bytes()
|
|
|
|
preview = preview_office_document(path, raw)
|
|
|
|
assert preview["preview_kind"] == "office"
|
|
assert preview["editable"] is False
|
|
assert preview.get("edit_blocked_reason")
|
|
|
|
with pytest.raises(ValueError):
|
|
save_office_document(path, raw, "edited text")
|
|
|
|
|
|
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
|
def test_workspace_office_state_block_routes_all_office_formats_through_office_save():
|
|
xlsx_state = _run_office_state_block(
|
|
{
|
|
"preview_kind": "office",
|
|
"office_format": "xlsx",
|
|
"editable": False,
|
|
"content": "sheet preview",
|
|
}
|
|
)
|
|
docx_state = _run_office_state_block(
|
|
{
|
|
"preview_kind": "office",
|
|
"office_format": "docx",
|
|
"editable": True,
|
|
"content": "doc preview",
|
|
}
|
|
)
|
|
|
|
assert xlsx_state["_previewSaveRoute"] == "/api/file/office-save"
|
|
assert xlsx_state["_previewServerEditable"] is False
|
|
assert xlsx_state["_previewOfficeFormat"] == "xlsx"
|
|
assert docx_state["_previewSaveRoute"] == "/api/file/office-save"
|
|
assert docx_state["_previewServerEditable"] is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Regression coverage for the four blockers found in the v0.51.802 gate review
|
|
# (all reproduced against legitimate-but-common inputs the earlier adversarial
|
|
# rounds never exercised).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _write_only_xlsx_bytes() -> bytes:
|
|
"""A workbook produced by openpyxl's streaming writer.
|
|
|
|
``Workbook(write_only=True)`` omits the ``<dimension>`` record, so when the
|
|
preview re-opens it in read_only mode ``sheet.max_row``/``max_column`` are
|
|
``None``. This is exactly issue #540's own use case (agents streaming large
|
|
sheets) and previously crashed the preview with a ``TypeError`` 500.
|
|
"""
|
|
workbook = Workbook(write_only=True)
|
|
sheet = workbook.create_sheet("Streamed")
|
|
sheet.append(["alpha", "beta", "gamma"])
|
|
sheet.append(["one", "two", "three"])
|
|
buffer = io.BytesIO()
|
|
workbook.save(buffer)
|
|
return buffer.getvalue()
|
|
|
|
|
|
def _multi_run_docx_bytes() -> bytes:
|
|
"""A docx whose paragraph spans multiple runs with a run-boundary space.
|
|
|
|
``add_run("Hello ") + add_run("world")`` is ubiquitous in real Word files
|
|
(and trivially produced by agent code). Each run is a separate ``<w:t>``
|
|
node; the preview previously ``.strip()``-ed each node and concatenated with
|
|
no separator, corrupting the text to "Helloworld".
|
|
"""
|
|
document = DocxDocument()
|
|
paragraph = document.add_paragraph()
|
|
paragraph.add_run("Hello ")
|
|
paragraph.add_run("world")
|
|
document.add_paragraph("Second paragraph.")
|
|
buffer = io.BytesIO()
|
|
document.save(buffer)
|
|
return buffer.getvalue()
|
|
|
|
|
|
def test_write_only_xlsx_without_dimension_previews_without_crash():
|
|
"""BLOCKING 1: dimension-less xlsx (max_row/max_column None) must not 500."""
|
|
raw = _write_only_xlsx_bytes()
|
|
# Must not raise TypeError — the pre-fix code did `min(None, CAP)`.
|
|
preview = preview_office_document("streamed.xlsx", raw)
|
|
assert preview["preview_kind"] == "office"
|
|
assert preview["office_format"] == "xlsx"
|
|
# The streamed cell values must actually appear in the preview.
|
|
assert "alpha" in preview["content"]
|
|
assert "three" in preview["content"]
|
|
|
|
|
|
def test_multi_run_docx_preserves_run_boundary_whitespace():
|
|
"""BLOCKING 2 (preview): run-boundary space must survive extraction."""
|
|
raw = _multi_run_docx_bytes()
|
|
preview = preview_office_document("multi-run.docx", raw)
|
|
assert "Hello world" in preview["content"]
|
|
assert "Helloworld" not in preview["content"]
|
|
|
|
|
|
def test_multi_run_docx_round_trips_text_identically_on_save():
|
|
"""BLOCKING 2 (save): opening the editor and saving unchanged preview text
|
|
must NOT corrupt the on-disk document. This is the data-loss path: the
|
|
editor textarea is prefilled from the preview text, so a no-op save writes
|
|
the preview text back verbatim."""
|
|
raw = _multi_run_docx_bytes()
|
|
preview = preview_office_document("multi-run.docx", raw)
|
|
assert preview["editable"] is True
|
|
|
|
# Simulate a save with the UNMODIFIED preview text (the no-op-save path).
|
|
_saved_preview, saved_bytes = save_office_document(
|
|
"multi-run.docx", raw, preview["content"]
|
|
)
|
|
|
|
# Read the saved bytes back and confirm the paragraph text is intact.
|
|
reloaded = DocxDocument(io.BytesIO(saved_bytes))
|
|
paragraph_texts = [p.text for p in reloaded.paragraphs]
|
|
assert "Hello world" in paragraph_texts
|
|
assert "Helloworld" not in paragraph_texts
|
|
|
|
|
|
def test_corrupt_archive_member_raises_value_error_not_unhandled():
|
|
"""BLOCKING 3: a valid zip central directory with corrupt member DATA (bad
|
|
CRC / truncated) must raise the module's ValueError (handled as a clean
|
|
error) instead of an unhandled BadZipFile/zlib.error that escapes as a 500."""
|
|
raw = bytearray(_simple_docx_bytes("hello"))
|
|
# Corrupt the deflate stream bytes without touching the central directory,
|
|
# so the archive opens but a member.read() fails a CRC/zlib check. Flip a
|
|
# run of bytes in the middle of the compressed payload region.
|
|
start = len(raw) // 3
|
|
for i in range(start, start + 64):
|
|
raw[i] ^= 0xFF
|
|
with pytest.raises(ValueError):
|
|
preview_office_document("corrupt.docx", bytes(raw))
|
|
|
|
|
|
def test_archive_limit_error_still_raised_over_corrupt_catch():
|
|
"""BLOCKING 3 guard: the limit-exceeded ValueError raised INSIDE the member
|
|
read loop must not be swallowed by the new corrupt-member except clause
|
|
(it catches only decompression exceptions, never ValueError)."""
|
|
# A docx whose main document part inflates beyond the per-member cap.
|
|
document = DocxDocument()
|
|
document.add_paragraph("x" * 5_000)
|
|
buffer = io.BytesIO()
|
|
document.save(buffer)
|
|
raw = buffer.getvalue()
|
|
# Temporarily force a tiny member cap so the real (well-formed) archive
|
|
# trips the limit path — proving the limit ValueError propagates.
|
|
original = office_documents.MAX_OFFICE_ARCHIVE_MEMBER_BYTES
|
|
office_documents.MAX_OFFICE_ARCHIVE_MEMBER_BYTES = 10
|
|
try:
|
|
with pytest.raises(ValueError):
|
|
preview_office_document("big.docx", raw)
|
|
finally:
|
|
office_documents.MAX_OFFICE_ARCHIVE_MEMBER_BYTES = original
|
|
|
|
|
|
def _authored_docx_bytes() -> bytes:
|
|
"""A docx carrying core-properties metadata and body text (like every
|
|
Word-authored file)."""
|
|
document = DocxDocument()
|
|
document.core_properties.author = "Jane Author"
|
|
document.core_properties.title = "Quarterly Report"
|
|
document.add_paragraph("Body text here")
|
|
buffer = io.BytesIO()
|
|
document.save(buffer)
|
|
return buffer.getvalue()
|
|
|
|
|
|
def test_unedited_docx_save_preserves_core_properties():
|
|
"""Round-trip fidelity: an unedited open->save must NOT wipe author/title.
|
|
|
|
The pre-fix save rebuilt the package from python-docx's blank template, so
|
|
metadata/styles were silently lost even when the user changed nothing (the
|
|
editor prefills the textarea from the preview). Rebuilding from the current
|
|
package preserves docProps/styles/theme."""
|
|
raw = _authored_docx_bytes()
|
|
preview = preview_office_document("authored.docx", raw)
|
|
assert preview["editable"] is True
|
|
|
|
_saved_preview, saved_bytes = save_office_document(
|
|
"authored.docx", raw, preview["content"]
|
|
)
|
|
reloaded = DocxDocument(io.BytesIO(saved_bytes))
|
|
assert reloaded.core_properties.author == "Jane Author"
|
|
assert reloaded.core_properties.title == "Quarterly Report"
|
|
assert "Body text here" in [p.text for p in reloaded.paragraphs]
|
|
|
|
|
|
def test_oversized_docx_save_content_rejected_before_build():
|
|
"""Write-surface DoS guard: content exceeding the editable char/line caps is
|
|
rejected up front, before the quadratic add_paragraph build loop runs."""
|
|
raw = _authored_docx_bytes()
|
|
# Exceed the paragraph-count cap (MAX_DOCX_PREVIEW_BLOCKS).
|
|
too_many_lines = "\n".join("x" for _ in range(office_documents.MAX_DOCX_PREVIEW_BLOCKS + 50))
|
|
with pytest.raises(ValueError):
|
|
save_office_document("authored.docx", raw, too_many_lines)
|
|
# Exceed the char cap with few lines.
|
|
too_many_chars = "a" * (office_documents.MAX_OFFICE_PREVIEW_CHARS + 1)
|
|
with pytest.raises(ValueError):
|
|
save_office_document("authored.docx", raw, too_many_chars)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"paragraphs",
|
|
[
|
|
["Hello", "", ""], # trailing blank paragraphs (normal Word convention)
|
|
["", "World"], # leading blank paragraph
|
|
["a", "", "b"], # interior blank (already worked — guard against regression)
|
|
],
|
|
)
|
|
def test_unedited_docx_save_preserves_edge_blank_paragraphs(paragraphs):
|
|
"""Round-trip fidelity: leading/trailing blank paragraphs must survive an
|
|
unedited open->save. _finalize_preview_text used to .strip() the whole
|
|
preview, and since the editor textarea is prefilled from the preview text,
|
|
a no-op save silently dropped edge blank paragraphs. The docx preview path
|
|
now preserves edge whitespace (strip_edges=False)."""
|
|
document = DocxDocument()
|
|
for text in paragraphs:
|
|
document.add_paragraph(text)
|
|
buffer = io.BytesIO()
|
|
document.save(buffer)
|
|
raw = buffer.getvalue()
|
|
|
|
preview = preview_office_document("edge.docx", raw)
|
|
assert preview["editable"] is True
|
|
|
|
_saved_preview, saved_bytes = save_office_document("edge.docx", raw, preview["content"])
|
|
reloaded = [p.text for p in DocxDocument(io.BytesIO(saved_bytes)).paragraphs]
|
|
assert reloaded == paragraphs
|