package jnpf.fileStorage.dms; import jnpf.exception.DataException; import org.junit.Test; import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; import java.io.*; import java.nio.charset.StandardCharsets; import java.util.*; import java.util.zip.*; import static org.junit.Assert.*; public class DmsDocxCommentCleanerTest { private final DmsDocxCommentCleaner cleaner = new DmsDocxCommentCleaner(); private static final String W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; private static final String R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/"; private static final String REL = "http://schemas.openxmlformats.org/package/2006/relationships"; private static byte[] bytes(String s) { return s.getBytes(StandardCharsets.UTF_8); } private Map fixture() { Map p = new LinkedHashMap<>(); p.put("[Content_Types].xml", bytes("" + "" + "" + "")); p.put("word/document.xml", bytes("" + "正文与签批页" + "修订保留" + "删除痕迹保留")); p.put("word/header1.xml", bytes("页眉")); p.put("word/notes.xml", bytes("秘密批注")); p.put("word/thread.xml", bytes("")); p.put("word/_rels/document.xml.rels", bytes("" + link("c", R + "comments", "notes.xml") + link("t", "http://schemas.microsoft.com/office/2011/relationships/commentsExtended", "thread.xml") + link("i", R + "image", "media/shared.png") + "")); p.put("word/_rels/notes.xml.rels", bytes("" + link("i", R + "image", "media/comment.png") + link("s", R + "image", "media/shared.png") + "")); p.put("word/media/shared.png", new byte[]{1, 2, 3}); p.put("word/media/comment.png", new byte[]{4, 5}); p.put("word/styles.xml", bytes("")); return p; } private static String link(String id, String type, String target) { return ""; } private MultipartFile file(Map parts) throws Exception { ByteArrayOutputStream out = new ByteArrayOutputStream(); try (ZipOutputStream zip = new ZipOutputStream(out)) { for (var part : parts.entrySet()) { zip.putNextEntry(new ZipEntry(part.getKey())); zip.write(part.getValue()); zip.closeEntry(); } } return new MockMultipartFile("file", "正式稿.DOCX", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", out.toByteArray()); } private Map unzip(MultipartFile f) throws Exception { Map out = new LinkedHashMap<>(); try (ZipInputStream zip = new ZipInputStream(f.getInputStream())) { ZipEntry e; while ((e = zip.getNextEntry()) != null) out.put(e.getName(), zip.readAllBytes()); } return out; } @Test public void removesCommentsAndRejectsRevisionsWithoutChangingSource() throws Exception { Map original = fixture(); MultipartFile source = file(original); byte[] before = source.getBytes(); MultipartFile cleaned = cleaner.clean(source); Map result = unzip(cleaned); assertArrayEquals(before, source.getBytes()); assertFalse(result.containsKey("word/notes.xml")); assertFalse(result.containsKey("word/thread.xml")); assertFalse(result.containsKey("word/_rels/notes.xml.rels")); assertFalse(result.containsKey("word/media/comment.png")); assertArrayEquals(original.get("word/media/shared.png"), result.get("word/media/shared.png")); assertArrayEquals(original.get("word/styles.xml"), result.get("word/styles.xml")); String body = new String(result.get("word/document.xml"), StandardCharsets.UTF_8); assertTrue(body.contains("正文与签批页")); assertFalse(body.contains("w:ins")); assertFalse(body.contains("w:del")); assertFalse(body.contains("修订保留")); assertTrue(body.contains("删除痕迹保留")); assertTrue(body.contains("w:b")); for (byte[] part : result.values()) { String xml = new String(part, StandardCharsets.UTF_8); assertFalse(xml.contains("commentRange")); assertFalse(xml.contains("commentReference")); assertFalse(xml.contains("notes.xml")); assertFalse(xml.contains("thread.xml")); } assertSame(cleaned, cleaner.clean(cleaned)); } @Test public void removesAllModernMetadataViaTypesAndStrictMarkers() throws Exception { Map p = fixture(); p.put("word/document.xml", bytes(new String(p.get("word/document.xml"), StandardCharsets.UTF_8).replace(W, "http://purl.oclc.org/ooxml/wordprocessingml/main"))); for (String kind : List.of("commentsIds", "commentsExtensible", "people")) { String types = new String(p.get("[Content_Types].xml"), StandardCharsets.UTF_8); p.put("[Content_Types].xml", bytes(types.replace("", ""))); p.put("word/" + kind + ".xml", bytes("")); } Map result = unzip(cleaner.clean(file(p))); for (String kind : List.of("commentsIds", "commentsExtensible", "people")) assertFalse(result.containsKey("word/" + kind + ".xml")); assertFalse(new String(result.get("word/document.xml"), StandardCharsets.UTF_8).contains("commentRange")); } @Test public void failsClosedOnCorruptXmlUnsafePackageAndDigitalSignature() throws Exception { Map p = fixture(); p.put("word/document.xml", bytes("]>&x;")); MultipartFile external = file(p); assertThrows(DataException.class, () -> cleaner.clean(external)); p = fixture(); p.put("../bad", bytes("bad")); MultipartFile unsafe = file(p); assertThrows(DataException.class, () -> cleaner.clean(unsafe)); p = fixture(); p.put("_xmlsignatures/sig1.xml", bytes("")); MultipartFile signed = file(p); assertThrows(DataException.class, () -> cleaner.clean(signed)); assertThrows(DataException.class, () -> cleaner.clean(new MockMultipartFile("f", "bad.docx", null, bytes("invalid zip")))); } @Test public void actualMergedDocxReopensWithApprovalPageAndRevisionsRejected() throws Exception { org.docx4j.openpackaging.packages.WordprocessingMLPackage document = org.docx4j.openpackaging.packages.WordprocessingMLPackage.createPackage(); ByteArrayOutputStream output = new ByteArrayOutputStream(); document.save(output); Map parts = unzip(new MockMultipartFile("f", "main.docx", null, output.toByteArray())); Map comments = fixture(); parts.put("word/document.xml", comments.get("word/document.xml")); parts.put("word/notes.xml", comments.get("word/notes.xml")); String types = new String(parts.get("[Content_Types].xml"), StandardCharsets.UTF_8); parts.put("[Content_Types].xml", bytes(types.replace("", ""))); String links = new String(parts.get("word/_rels/document.xml.rels"), StandardCharsets.UTF_8); parts.put("word/_rels/document.xml.rels", bytes(links.replace("", link("comment1", R + "comments", "notes.xml") + ""))); org.docx4j.openpackaging.packages.WordprocessingMLPackage approval = org.docx4j.openpackaging.packages.WordprocessingMLPackage.createPackage(); approval.getMainDocumentPart().addParagraphOfText("APPROVAL SIGNATURE PAGE"); ByteArrayOutputStream pageBytes = new ByteArrayOutputStream(); approval.save(pageBytes); DmsApprovalPageMergeService merge = new DmsApprovalPageMergeService(new jnpf.service.DmsApprovalPageTagService()); MultipartFile merged = merge.replaceOrPrepend(file(parts), new MockMultipartFile("f", "approval.docx", null, pageBytes.toByteArray())); MultipartFile result = cleaner.clean(merged); assertTrue(merge.hasApprovalPage(result)); var reopened = org.docx4j.openpackaging.packages.WordprocessingMLPackage.load(result.getInputStream()); String body = org.docx4j.XmlUtils.marshaltoString(reopened.getMainDocumentPart().getJaxbElement(), true, true); assertTrue(body.contains("APPROVAL SIGNATURE PAGE")); assertFalse(body.contains("修订保留")); assertTrue(body.contains("删除痕迹保留")); assertFalse(body.contains("commentReference")); assertFalse(unzip(result).containsKey("word/notes.xml")); } @Test public void restoresFormattingMovesDeletedRowsAndParagraphBreaks() throws Exception { Map parts = fixture(); parts.put("word/document.xml", bytes("" + "first" + "second" + "original-place" + "new-place" + "inserted-row" + "restored-row" + "")); String body = new String(unzip(cleaner.clean(file(parts))).get("word/document.xml"), StandardCharsets.UTF_8); assertTrue(body.contains("first")); assertTrue(body.contains("second")); assertTrue(body.contains("w:i")); assertFalse(body.contains("w:b/")); assertTrue(body.contains("original-place")); assertFalse(body.contains("new-place")); assertTrue(body.contains("restored-row")); assertFalse(body.contains("inserted-row")); for (String marker : List.of("w:ins", "w:del", "w:moveFrom", "w:moveTo", "w:rPrChange")) assertFalse(body.contains(marker)); assertEquals(2, body.split("", -1).length - 1); } @Test public void rejectsRevisionsInHeadersAndStrictDocuments() throws Exception { Map parts = fixture(); parts.put("word/header1.xml", bytes("" + "new-headerold-header" + "")); String header = new String(unzip(cleaner.clean(file(parts))).get("word/header1.xml"), StandardCharsets.UTF_8); assertFalse(header.contains("new-header")); assertTrue(header.contains("old-header")); assertFalse(header.contains("w:del")); } @Test public void unsupportedStructuralRevisionsFailBeforeStorage() throws Exception { for (String marker : List.of("cellMerge", "numberingChange", "customXmlDelRangeStart")) { Map parts = fixture(); parts.put("word/document.xml", bytes("")); MultipartFile source = file(parts); assertThrows(DataException.class, () -> cleaner.clean(source)); } } private org.w3c.dom.Document cleanBody(String body) throws Exception { Map parts = fixture(); parts.put("word/document.xml", bytes("" + body + "")); MultipartFile cleaned = cleaner.clean(file(parts)); assertSame("Cleaning must be idempotent", cleaned, cleaner.clean(cleaned)); var factory = javax.xml.parsers.DocumentBuilderFactory.newDefaultInstance(); factory.setNamespaceAware(true); return factory.newDocumentBuilder().parse(new ByteArrayInputStream(unzip(cleaned).get("word/document.xml"))); } @Test public void paragraphMergeKeepsPropertiesFirstDespiteWhitespaceAndComments() throws Exception { var doc = cleanBody("A" + "\n \n" + "B"); var paragraphs = doc.getElementsByTagNameNS(W, "p"); assertEquals(1, paragraphs.getLength()); var paragraph = paragraphs.item(0); org.w3c.dom.Node first = paragraph.getFirstChild(); while (!(first instanceof org.w3c.dom.Element)) first = first.getNextSibling(); assertEquals("pPr", first.getLocalName()); assertEquals("center", ((org.w3c.dom.Element) doc.getElementsByTagNameNS(W, "jc").item(0)).getAttributeNS(W, "val")); var text = doc.getElementsByTagNameNS(W, "t"); assertEquals(2, text.getLength()); assertEquals("A", text.item(0).getTextContent()); assertEquals("B", text.item(1).getTextContent()); } @Test public void rejectsWholeAndConsecutiveParagraphMovesWithoutEmptyDestinationParagraphs() throws Exception { for (int count : List.of(1, 2)) { StringBuilder body = new StringBuilder(); for (int i = 0; i < count; i++) body.append("" + "original" + i + ""); body.append("middle"); for (int i = 0; i < count; i++) body.append("" + "original" + i + ""); body.append("\nend"); var doc = cleanBody(body.toString()); var paragraphs = doc.getElementsByTagNameNS(W, "p"); assertEquals(count + 2, paragraphs.getLength()); for (int i = 0; i < count; i++) assertEquals("original" + i, paragraphs.item(i).getTextContent()); assertEquals("middle", paragraphs.item(count).getTextContent()); assertEquals("end", paragraphs.item(count + 1).getTextContent().trim()); assertEquals("right", ((org.w3c.dom.Element) doc.getElementsByTagNameNS(W, "jc").item(0)).getAttributeNS(W, "val")); assertEquals(0, doc.getElementsByTagNameNS(W, "moveFrom").getLength()); assertEquals(0, doc.getElementsByTagNameNS(W, "moveTo").getLength()); } } @Test public void paragraphMarkRejectionFailsClosedAtStructuralBoundaries() throws Exception { for (String mark : List.of("ins", "moveTo")) { for (String boundary : List.of("", "", "")) { assertThrows(DataException.class, () -> cleanBody("text" + boundary)); } } } @Test public void nonDocxIsUntouched() { MultipartFile source = new MockMultipartFile("f", "original.pdf", null, bytes("pdf")); assertSame(source, cleaner.clean(source)); } }