package jnpf.elnService.impl; import feign.Request; import feign.Response; import jnpf.base.ActionResult; import jnpf.elnEntity.field.ElnFieldVersionEntity; import jnpf.elnEntity.runtime.ElnRecordEntity; import jnpf.elnEntity.runtime.ElnRecordFieldValueEntity; import jnpf.elnEntity.runtime.ElnRecordForms; import jnpf.elnEntity.runtime.ElnRecordRevisionEntity; import jnpf.elnEntity.runtime.ElnRecordVos; import jnpf.elnEntity.template.ElnTemplateBindingEntity; import jnpf.elnMapper.ElnFieldVersionMapper; import jnpf.elnMapper.ElnRecordFieldValueMapper; import jnpf.elnMapper.ElnRecordMapper; import jnpf.elnMapper.ElnRecordRevisionMapper; import jnpf.elnMapper.ElnTemplateBindingMapper; import jnpf.elnService.ElnTestWorkItemSnapshotProvider; import jnpf.elnService.ElnRecordFormulaCalculationService; import jnpf.elnService.template.docx.DocxContentControlReader; import jnpf.elnService.template.docx.DocxContentControlWriter; import jnpf.elnService.template.docx.DocxControlSnapshot; import jnpf.exception.DataException; import jnpf.file.FileApi; import jnpf.file.FileUploadApi; import jnpf.lims.api.model.TestWorkItemSnapshotBatch; import jnpf.lims.api.model.TestWorkItemSnapshot; import jnpf.model.upload.UploadFileModel; import jnpf.onlyoffice.RecordRevisionFenceApi; import org.junit.Test; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.multipart.MultipartFile; import java.io.ByteArrayInputStream; import java.io.IOException; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Proxy; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import java.io.ByteArrayOutputStream; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertArrayEquals; public class ElnRecordSharedFillDocumentIntegrationTest { @Test public void executeWritesPrimaryAndReferencesAndRotatesOnlyOfficeDocumentKey() throws Exception { Fixture fixture = new Fixture(false); ElnRecordVos.SharedFilled result = fixture.service.execute(fixture.command()); assertEquals(result.getTargets().get(0).getErrorMessage(), "COMMITTED", result.getTargets().get(0).getStatus()); assertEquals(Arrays.asList("acquire", "release-updated"), fixture.fenceEvents); List controls = new DocxContentControlReader().read( new ByteArrayInputStream(fixture.files.get("record-1.docx"))); assertEquals(3, controls.size()); for (DocxControlSnapshot control : controls) { assertEquals("20.50", control.getCurrentValue()); assertNotNull(control.getAlias()); assertEquals("sdtLocked", control.getLock()); } assertTrue(controls.get(0).getTag().startsWith("eln.field.")); } @Test public void executeRestoresOriginalDocxWhenTheTargetTransactionRollsBack() throws Exception { Fixture fixture = new Fixture(true); byte[] original = fixture.files.get("record-1.docx").clone(); ElnRecordVos.SharedFilled result = fixture.service.execute(fixture.command()); assertEquals("REJECTED", result.getTargets().get(0).getStatus()); assertEquals(Arrays.asList("acquire", "release"), fixture.fenceEvents); assertArrayEquals(original, fixture.files.get("record-1.docx")); } @Test public void executeRejectsTheTargetWhenAnActiveReferenceTagIsMissing() throws Exception { Fixture fixture = new Fixture(false); fixture.files.put("record-1.docx", docxWithoutFooter("old")); byte[] original = fixture.files.get("record-1.docx").clone(); ElnRecordVos.SharedFilled result = fixture.service.execute(fixture.command()); assertEquals("REJECTED", result.getTargets().get(0).getStatus()); assertEquals("DOCX 缺少目标字段控件: eln.ref.environment_temperature.footer", result.getTargets().get(0).getErrorMessage()); assertEquals(Arrays.asList("acquire", "release"), fixture.fenceEvents); assertArrayEquals(original, fixture.files.get("record-1.docx")); } @Test public void executeRejectsADocxWithAPathTraversalZipEntry() throws Exception { Fixture fixture = new Fixture(false); fixture.files.put("record-1.docx", docxWithUnsafeEntry("old")); byte[] original = fixture.files.get("record-1.docx").clone(); ElnRecordVos.SharedFilled result = fixture.service.execute(fixture.command()); assertEquals("REJECTED", result.getTargets().get(0).getStatus()); assertEquals("DOCX ZIP 条目不允许路径跳转", result.getTargets().get(0).getErrorMessage()); assertEquals(Arrays.asList("acquire", "release"), fixture.fenceEvents); assertArrayEquals(original, fixture.files.get("record-1.docx")); } @Test public void executeRejectsADocxXmlPartContainingADoctype() throws Exception { Fixture fixture = new Fixture(false); fixture.files.put("record-1.docx", docxWithDoctype("old")); byte[] original = fixture.files.get("record-1.docx").clone(); ElnRecordVos.SharedFilled result = fixture.service.execute(fixture.command()); assertEquals("REJECTED", result.getTargets().get(0).getStatus()); assertTrue(result.getTargets().get(0).getErrorMessage() .startsWith("DOCX 内容控件写入失败")); assertEquals(Arrays.asList("acquire", "release"), fixture.fenceEvents); assertArrayEquals(original, fixture.files.get("record-1.docx")); } @Test public void executeClearsAnEmptyFormulaResultAndItsReferences() throws Exception { Fixture fixture = new Fixture(false, true); ElnRecordVos.SharedFilled result = fixture.service.execute(fixture.command()); assertEquals(result.getTargets().get(0).getErrorMessage(), "COMMITTED", result.getTargets().get(0).getStatus()); List controls = new DocxContentControlReader().read( new ByteArrayInputStream(fixture.files.get("record-1.docx"))); Map values = new LinkedHashMap<>(); for (DocxControlSnapshot control : controls) { values.put(control.getTag(), control.getCurrentValue()); } assertEquals(null, values.get("eln.field.formula_result")); assertEquals(null, values.get("eln.ref.formula_result")); } private static ElnRecordVos.SharedFillTargetResult completeTransaction( java.util.function.Supplier action, boolean rollback) { TransactionSynchronizationManager.initSynchronization(); try { ElnRecordVos.SharedFillTargetResult result = action.get(); for (TransactionSynchronization synchronization : TransactionSynchronizationManager.getSynchronizations()) { synchronization.afterCompletion(rollback ? TransactionSynchronization.STATUS_ROLLED_BACK : TransactionSynchronization.STATUS_COMMITTED); } if (rollback) throw new DataException("模拟目标事务提交失败"); return result; } finally { TransactionSynchronizationManager.clearSynchronization(); } } private static final class Fixture { private final Map files = new LinkedHashMap<>(); private final List fenceEvents = new ArrayList<>(); private final ElnRecordSharedFillServiceImpl service; private Fixture(boolean rollback) throws IOException { this(rollback, false); } private Fixture(boolean rollback, boolean emptyFormula) throws IOException { Map records = new LinkedHashMap<>(); records.put("item-1", record("record-1", "item-1", "revision-1", 3)); records.put("item-2", record("record-2", "item-2", "revision-2", 5)); Map revisions = new LinkedHashMap<>(); revisions.put("record-1", revision( "revision-1", "record-1", "record-1.docx")); revisions.put("record-2", revision( "revision-2", "record-2", "record-2.docx")); Map> values = new LinkedHashMap<>(); values.put("record-1", Collections.singletonList(value( "value-1", "record-1", "revision-1"))); values.put("record-2", Collections.singletonList(value( "value-2", "record-2", "revision-2"))); final String[] currentItem = new String[1]; ElnRecordMapper recordMapper = proxy( ElnRecordMapper.class, (ignored, method, args) -> { if ("selectLiveByTestWorkItem".equals(method.getName())) { currentItem[0] = (String) args[1]; return records.get(currentItem[0]); } if ("advanceVersion".equals(method.getName())) return 1; return null; }); ElnRecordRevisionMapper revisionMapper = proxy( ElnRecordRevisionMapper.class, (ignored, method, args) -> "selectOne".equals(method.getName()) ? revisions.get(records.get(currentItem[0]).getId()) : null); ElnRecordFieldValueMapper valueMapper = proxy( ElnRecordFieldValueMapper.class, (ignored, method, args) -> { if ("selectList".equals(method.getName())) { return values.get(records.get(currentItem[0]).getId()); } if ("updateById".equals(method.getName())) return 1; return null; }); ElnFieldVersionMapper versionMapper = proxy( ElnFieldVersionMapper.class, (ignored, method, args) -> "selectOne".equals(method.getName()) ? sharedVersion() : null); ElnTemplateBindingMapper bindingMapper = proxy( ElnTemplateBindingMapper.class, (ignored, method, args) -> "selectList".equals(method.getName()) ? bindings(emptyFormula) : null); files.put("record-1.docx", docx("old", emptyFormula)); files.put("record-2.docx", docx("old", emptyFormula)); FileApi fileApi = proxy(FileApi.class, (ignored, method, args) -> "getPath".equals(method.getName()) ? "WebAnnexFile/" : null); FileUploadApi uploadApi = proxy( FileUploadApi.class, (ignored, method, args) -> { if ("downFile".equals(method.getName())) { UploadFileModel model = (UploadFileModel) args[0]; return response(files.get(model.getObjectName())); } if ("replaceFile".equals(method.getName())) { MultipartFile multipart = (MultipartFile) args[0]; files.put((String) args[2], multipart.getBytes()); return true; } return null; }); RecordRevisionFenceApi fenceApi = proxy( RecordRevisionFenceApi.class, (ignored, method, args) -> { if ("acquire".equals(method.getName())) { fenceEvents.add("acquire"); return ActionResult.success("fence acquired", "fence-token"); } if ("releaseAfterDocumentUpdate".equals(method.getName())) { fenceEvents.add("release-updated"); return ActionResult.success("fence released", true); } if ("release".equals(method.getName())) { fenceEvents.add("release"); return ActionResult.success("fence released", true); } return null; }); ElnRecordSharedFillDocumentService documentService = new ElnRecordSharedFillDocumentService(bindingMapper, fileApi, uploadApi, fenceApi, new DocxContentControlWriter()); service = new ElnRecordSharedFillServiceImpl( recordMapper, revisionMapper, valueMapper, versionMapper, snapshots(), calculation(emptyFormula), ignored -> { }, (action, failureAudit) -> completeTransaction(action, rollback), documentService::synchronize, () -> new ElnRecordSharedFillServiceImpl.UserContext( "tenant-a", "user-1")); } private ElnRecordForms.SharedFillExecute command() { ElnRecordForms.SharedFillPreview previewCommand = new ElnRecordForms.SharedFillPreview(); previewCommand.setTestWorkItemIds(Arrays.asList("item-1", "item-2")); ElnRecordVos.SharedFillPreview preview = service.preview(previewCommand); ElnRecordForms.SharedFillExecute command = new ElnRecordForms.SharedFillExecute(); command.setTestWorkItemIds(Arrays.asList("item-1", "item-2")); command.setPreviewToken(preview.getPreviewToken()); command.setOperationCode("shared-fill-1"); command.setFieldVersionId("version-shared"); command.setValue("20.50"); ElnRecordForms.SharedFillTarget target = new ElnRecordForms.SharedFillTarget(); target.setTestWorkItemId("item-1"); target.setRecordId("record-1"); target.setExpectedRecordVersion(3); command.setTargets(Collections.singletonList(target)); return command; } } private static ElnRecordFormulaCalculationService calculation(boolean emptyFormula) { return new ElnRecordFormulaCalculationService() { @Override public List recalculateAffected( String tenantId, ElnRecordEntity record, ElnRecordRevisionEntity revision, List currentValues, Set changedBindingIds, String operationCode, String operatorUserId) { return emptyFormula ? Collections.singletonList(emptyFormulaValue(record, revision)) : Collections.emptyList(); } @Override public List recalculateAll( String tenantId, ElnRecordEntity record, ElnRecordRevisionEntity revision, List currentValues, String operationCode, String operatorUserId) { return Collections.emptyList(); } }; } private static List bindings(boolean includeFormula) { ElnTemplateBindingEntity primary = binding( "binding-shared", "eln.field.environment_temperature", "PRIMARY", null); ElnTemplateBindingEntity header = binding( "reference-header", "eln.ref.environment_temperature.header", "REFERENCE", primary.getId()); ElnTemplateBindingEntity footer = binding( "reference-footer", "eln.ref.environment_temperature.footer", "REFERENCE", primary.getId()); List result = new ArrayList<>( Arrays.asList(primary, header, footer)); if (includeFormula) { ElnTemplateBindingEntity formula = binding( "binding-formula", "eln.field.formula_result", "PRIMARY", null); formula.setZiduanDingyiId("definition-formula"); formula.setZiduanBianma("formula_result"); formula.setZiduanBanbenId("version-formula"); ElnTemplateBindingEntity formulaReference = binding( "reference-formula", "eln.ref.formula_result", "REFERENCE", formula.getId()); formulaReference.setZiduanDingyiId("definition-formula"); formulaReference.setZiduanBianma("formula_result"); formulaReference.setZiduanBanbenId("version-formula"); result.add(formula); result.add(formulaReference); } return result; } private static ElnRecordFieldValueEntity emptyFormulaValue( ElnRecordEntity record, ElnRecordRevisionEntity revision) { ElnRecordFieldValueEntity value = new ElnRecordFieldValueEntity(); value.setId("value-formula"); value.setDianziJiluId(record.getId()); value.setXiudingId(revision.getId()); value.setMubanId("template-1"); value.setMubanBangdingId("binding-formula"); value.setZiduanDingyiId("definition-formula"); value.setZiduanBianma("formula_result"); value.setZiduanBanbenId("version-formula"); value.setBangdingJuese("PRIMARY"); value.setBangdingZhuangtai("ACTIVE"); value.setJisuanZhuangtai("MISSING_INPUT"); value.setFTenantId("tenant-a"); value.setFDeleteMark(0); return value; } private static ElnTemplateBindingEntity binding( String id, String tag, String role, String primaryId) { ElnTemplateBindingEntity value = new ElnTemplateBindingEntity(); value.setId(id); value.setMubanId("template-1"); value.setZiduanDingyiId("definition-shared"); value.setZiduanBianma("environment_temperature"); value.setZiduanBanbenId("version-shared"); value.setKongjianBiaoqian(tag); value.setKongjianLeixing("TEXT"); value.setKongjianJuese(role); value.setYinyongShiliId(primaryId); value.setBangdingZhuangtai("ACTIVE"); value.setFTenantId("tenant-a"); value.setFDeleteMark(0); return value; } private static ElnRecordEntity record( String id, String itemId, String revisionId, int version) { ElnRecordEntity value = new ElnRecordEntity(); value.setId(id); value.setJianyanXiangId(itemId); value.setMubanId("template-1"); value.setMubanBianhao("TPL-1"); value.setDangqianXiudingId(revisionId); value.setFVersion(version); value.setFTenantId("tenant-a"); value.setFDeleteMark(0); return value; } private static ElnRecordRevisionEntity revision( String id, String recordId, String fileId) { ElnRecordRevisionEntity value = new ElnRecordRevisionEntity(); value.setId(id); value.setDianziJiluId(recordId); value.setJiluWendang(fileId); value.setBizStatus("DRAFT"); value.setFTenantId("tenant-a"); value.setFDeleteMark(0); return value; } private static ElnRecordFieldValueEntity value( String id, String recordId, String revisionId) { ElnRecordFieldValueEntity value = new ElnRecordFieldValueEntity(); value.setId(id); value.setDianziJiluId(recordId); value.setXiudingId(revisionId); value.setMubanId("template-1"); value.setMubanBangdingId("binding-shared"); value.setZiduanDingyiId("definition-shared"); value.setZiduanBianma("environment_temperature"); value.setZiduanBanbenId("version-shared"); value.setBangdingJuese("PRIMARY"); value.setBangdingZhuangtai("ACTIVE"); value.setZhiLaiyuan("MANUAL"); value.setFTenantId("tenant-a"); value.setFDeleteMark(0); return value; } private static ElnFieldVersionEntity sharedVersion() { ElnFieldVersionEntity value = new ElnFieldVersionEntity(); value.setId("version-shared"); value.setZiduanDingyiId("definition-shared"); value.setZiduanMingcheng("环境温度"); value.setShujuLeixing("DECIMAL"); value.setPiciMoshi("BATCH_SHARED"); value.setBangdingPeizhi( "{\"read\":{\"source\":\"NONE\"},\"write\":{\"target\":\"ELN_RECORD\"}}"); value.setFTenantId("tenant-a"); value.setFDeleteMark(0); return value; } private static ElnTestWorkItemSnapshotProvider snapshots() { return new ElnTestWorkItemSnapshotProvider() { @Override public TestWorkItemSnapshotBatch resolve( List testWorkItemIds, List properties) { throw new AssertionError("must use actor snapshot"); } @Override public TestWorkItemSnapshotBatch resolveAsActor( String tenantId, String userId, List testWorkItemIds, List properties) { TestWorkItemSnapshotBatch batch = new TestWorkItemSnapshotBatch(); List items = new ArrayList<>(); for (String itemId : testWorkItemIds) { TestWorkItemSnapshot item = new TestWorkItemSnapshot(); item.setTestWorkItemId(itemId); item.setTestDefinitionId("project-1"); item.setTestDefinitionCode("HD_jyxm_0008"); item.setTestDefinitionName("pH值"); item.setOutcomeCode("SUCCESS"); item.setTaskStatus("pending_test"); items.add(item); } batch.setItems(items); return batch; } }; } private static byte[] docx(String text) throws IOException { return docx(text, true, false); } private static byte[] docxWithoutFooter(String text) throws IOException { return docx(text, false, false); } private static byte[] docx(String text, boolean includeFormula) throws IOException { return docx(text, true, includeFormula); } private static byte[] docx( String text, boolean includeFooter, boolean includeFormula) throws IOException { Map parts = standardParts(text, includeFormula); if (!includeFooter) parts.remove("word/footer1.xml"); return zip(parts); } private static byte[] docxWithUnsafeEntry(String text) throws IOException { Map parts = standardParts(text, false); parts.put("../outside.xml", "unsafe"); return zip(parts); } private static byte[] docxWithDoctype(String text) throws IOException { Map parts = standardParts(text, false); parts.put("word/document.xml", "" + "]>" + documentPart("eln.field.environment_temperature", "Primary", "&xxe;", "document") .replace("", "")); return zip(parts); } private static Map standardParts( String text, boolean includeFormula) { Map parts = new LinkedHashMap<>(); parts.put("[Content_Types].xml", ""); parts.put("word/document.xml", documentPart( "eln.field.environment_temperature", "Primary", text, "document")); parts.put("word/header1.xml", documentPart( "eln.ref.environment_temperature.header", "Header", text, "hdr")); parts.put("word/footer1.xml", documentPart( "eln.ref.environment_temperature.footer", "Footer", text, "ftr")); if (includeFormula) { parts.put("word/footnotes.xml", documentPart( "eln.field.formula_result", "Formula", "stale", "footnotes")); parts.put("word/endnotes.xml", documentPart( "eln.ref.formula_result", "Formula reference", "stale", "endnotes")); } return parts; } private static byte[] zip(Map parts) throws IOException { ByteArrayOutputStream output = new ByteArrayOutputStream(); try (ZipOutputStream zip = new ZipOutputStream(output)) { for (Map.Entry part : parts.entrySet()) { zip.putNextEntry(new ZipEntry(part.getKey())); zip.write(part.getValue().getBytes(StandardCharsets.UTF_8)); zip.closeEntry(); } } return output.toByteArray(); } private static String documentPart( String tag, String alias, String text, String root) { return "" + "" + "" + "" + "" + text + ""; } private static Response response(byte[] bytes) { return Response.builder().status(bytes == null ? 404 : 200) .reason(bytes == null ? "Not Found" : "OK") .request(Request.create(Request.HttpMethod.GET, "http://file/download", Collections.>emptyMap(), null, Charset.defaultCharset())) .body(bytes).build(); } @SuppressWarnings("unchecked") private static T proxy(Class type, InvocationHandler handler) { return (T) Proxy.newProxyInstance( type.getClassLoader(), new Class[]{type}, handler); } }