package jnpf.bizcommon.onlyoffice.service.impl; import cn.hutool.jwt.JWTUtil; import com.sun.net.httpserver.HttpServer; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.conditions.AbstractWrapper; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import jnpf.base.ActionResult; import jnpf.base.UserInfo; import jnpf.bizcommon.onlyoffice.entity.OnlyOfficeConfigParam; import jnpf.bizcommon.onlyoffice.entity.OnlyOfficeCallbackParam; import jnpf.bizcommon.onlyoffice.entity.OnlyOfficeProperties; import jnpf.bizcommon.onlyoffice.entity.OnlyOfficeSessionEntity; import jnpf.bizcommon.onlyoffice.mapper.OnlyOfficeSessionMapper; import jnpf.exception.DataException; import jnpf.eln.TemplateEditabilityApi; import jnpf.eln.RecordFillApi; import jnpf.eln.RecordFillSaveOperation; import jnpf.file.FileApi; import jnpf.file.FileUploadApi; import jnpf.util.JsonUtil; import jnpf.util.CacheKeyUtil; import jnpf.util.RedisUtil; import jnpf.util.UserProvider; import jnpf.util.context.SpringContext; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.springframework.dao.DuplicateKeyException; import org.springframework.context.ApplicationContext; import org.apache.ibatis.builder.MapperBuilderAssistant; import java.net.InetSocketAddress; import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; import java.nio.charset.Charset; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Date; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import feign.Request; import feign.Response; import org.dromara.x.file.storage.core.FileInfo; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class OnlyOfficeServiceImplTest { private static final int SAVING_STATUS = 2; private HttpServer server; private OnlyOfficeSessionMapper sessionMapper; private FileApi fileApi; private FileUploadApi fileUploadApi; private OnlyOfficePluginResolver pluginResolver; private OnlyOfficeAnnotateTicketIssuer annotateTicketIssuer; private ElnRecordFillTicketIssuer recordFillTicketIssuer; private TemplateEditabilityApi templateEditabilityApi; private RecordFillApi recordFillApi; private DmsOnlyOfficePreviewTicketValidator dmsTicketValidator; private AtomicInteger downloadRequests; private OnlyOfficeServiceImpl service; @Before public void setUp() throws Exception { TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), OnlyOfficeSessionEntity.class); ApplicationContext applicationContext = mock(ApplicationContext.class); when(applicationContext.getBean(RedisUtil.class)).thenReturn(mock(RedisUtil.class)); when(applicationContext.getBean(CacheKeyUtil.class)).thenReturn(mock(CacheKeyUtil.class)); SpringContext.setApplicationContext(applicationContext); downloadRequests = new AtomicInteger(); server = HttpServer.create(new InetSocketAddress(0), 0); server.createContext("/saved.docx", exchange -> { downloadRequests.incrementAndGet(); byte[] body = "saved-document".getBytes(); exchange.sendResponseHeaders(200, body.length); exchange.getResponseBody().write(body); exchange.close(); }); server.start(); sessionMapper = mock(OnlyOfficeSessionMapper.class); when(sessionMapper.update(isNull(), any())).thenReturn(1); fileApi = mock(FileApi.class); fileUploadApi = mock(FileUploadApi.class); when(fileApi.getPath("annex")).thenReturn("WebAnnexFile"); OnlyOfficeProperties properties = new OnlyOfficeProperties(); properties.setJwtSecret("test-secret"); properties.setFileFetchBaseUrl("http://gateway"); pluginResolver = mock(OnlyOfficePluginResolver.class); annotateTicketIssuer = mock(OnlyOfficeAnnotateTicketIssuer.class); recordFillTicketIssuer = mock(ElnRecordFillTicketIssuer.class); templateEditabilityApi = mock(TemplateEditabilityApi.class); recordFillApi = mock(RecordFillApi.class); ActionResult editable = probeResult(200, true); when(templateEditabilityApi.editable(any(), any(), any())).thenReturn(editable); when(templateEditabilityApi.canAnnotate(any(), any(), any(), any())).thenReturn(editable); OnlyOfficeSceneConfigRegistry sceneRegistry = new OnlyOfficeSceneConfigRegistry( java.util.Arrays.asList( new ElnTemplateAnnotateConfigBuilder(templateEditabilityApi, annotateTicketIssuer), new ElnRecordFillConfigBuilder(recordFillApi, recordFillTicketIssuer)), new DefaultSceneConfigBuilder(new OnlyOfficeEditPermissionEvaluator(properties))); dmsTicketValidator = mock(DmsOnlyOfficePreviewTicketValidator.class); service = new OnlyOfficeServiceImpl(properties, sessionMapper, pluginResolver, sceneRegistry, templateEditabilityApi, recordFillApi, dmsTicketValidator, fileApi, fileUploadApi, new DmsQianpiyeDocxPropertiesWriter()); } @After public void tearDown() { UserProvider.clearLocalUser(); server.stop(0); } @Test public void saveBackReplacesOriginalFile() { OnlyOfficeSessionEntity session = session(); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); when(fileUploadApi.replaceFile(any(), eq("WebAnnexFile/"), eq("template.docx"))).thenReturn(true); invokeSaveBack(session, callback(), true); verify(fileUploadApi).replaceFile(any(), eq("WebAnnexFile/"), eq("template.docx")); verify(fileUploadApi, never()).uploadFile(any(), any(), any()); assertEquals(Integer.valueOf(2), session.getVersionNo()); verify(templateEditabilityApi).editable("tenant-a", "template-1", "template.docx"); } @Test @SuppressWarnings("unchecked") public void qianpiyeSaveBackCreatesNewFileAndReportsUploadItem() { OnlyOfficeSessionEntity session = session(); session.setBizModule("dms"); session.setBizScene("dms.qianpiye"); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); FileInfo stored = mock(FileInfo.class); when(stored.getFilename()).thenReturn("saved-copy.docx"); when(stored.getSize()).thenReturn(321L); when(fileUploadApi.uploadFile(any(), eq("WebAnnexFile/"), eq("template.docx"))).thenReturn(stored); invokeSaveBack(session, callback(), true); verify(fileUploadApi).uploadFile(any(), eq("WebAnnexFile/"), eq("template.docx")); verify(fileUploadApi, never()).replaceFile(any(), any(), any()); Map file = JsonUtil.stringToMap(session.getQianpiyeSavedFileJson()); assertEquals("saved-copy.docx", file.get("fileId")); assertEquals("template.docx", file.get("name")); assertEquals(321L, ((Number) file.get("fileSize")).longValue()); assertEquals("docx", file.get("fileExtension")); assertTrue(String.valueOf(file.get("url")).contains("saved-copy.docx")); } @Test @SuppressWarnings("unchecked") public void qianpiyeSaveBackReusesTheFirstCopyWithinOneEditingSession() { OnlyOfficeSessionEntity session = session(); session.setBizModule("dms"); session.setBizScene("dms.qianpiye"); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); Map firstCopy = new LinkedHashMap<>(); firstCopy.put("fileId", "saved-copy.docx"); firstCopy.put("name", "template.docx"); firstCopy.put("fileSize", 1L); firstCopy.put("fileExtension", "docx"); firstCopy.put("url", "/api/file/saved-copy.docx"); session.setQianpiyeSavedFileJson(JsonUtil.getObjectToString(firstCopy)); when(fileUploadApi.replaceFile(any(), eq("WebAnnexFile/"), eq("saved-copy.docx"))).thenReturn(true); invokeSaveBack(session, callback(), false); verify(fileUploadApi).replaceFile(any(), eq("WebAnnexFile/"), eq("saved-copy.docx")); verify(fileUploadApi, never()).uploadFile(any(), any(), any()); Map file = JsonUtil.stringToMap(session.getQianpiyeSavedFileJson()); assertEquals("saved-copy.docx", file.get("fileId")); assertEquals("template.docx", file.get("name")); assertEquals("saved-document".getBytes().length, ((Number) file.get("fileSize")).intValue()); } @Test public void qianpiyeNewEditingLeaseClearsThePreviousCopy() { OnlyOfficeSessionEntity session = session(); session.setBizScene("dms.qianpiye"); session.setQianpiyeSavedFileJson("{\"fileId\":\"previous-copy.docx\"}"); when(sessionMapper.update(any(OnlyOfficeSessionEntity.class), any())).thenReturn(1); invokePrivate("acquireLock", new Class[]{OnlyOfficeSessionEntity.class, String.class}, session, "user-1"); assertNull(session.getQianpiyeSavedFileJson()); assertEquals(Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.EDITING), session.getSessionStatus()); } @Test public void saveStatusIsLoadedFromCurrentTenantSession() { OnlyOfficeSessionEntity session = session(); Date savedAt = new Date(1_700_000_000_000L); session.setLastSaveTime(savedAt); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); when(sessionMapper.selectOne(any())).thenReturn(session); UserProvider.setLocalLoginUser(user()); Map status = service.getSaveStatus("template.docx", "annex"); assertEquals(savedAt.getTime(), status.get("lastSaveTime")); assertEquals("EDITING", status.get("sessionStatus")); verify(sessionMapper).selectOne(argThat(query -> { AbstractWrapper wrapper = (AbstractWrapper) query; wrapper.getSqlSegment(); return wrapper.getParamNameValuePairs().containsValue("tenant-a") && wrapper.getParamNameValuePairs().containsValue("template.docx") && wrapper.getParamNameValuePairs().containsValue("annex"); })); } @Test public void uploadStatusReturnsOnlyProgressAfterLeaseOwnershipCheck() throws Exception { OnlyOfficeSessionEntity session = session(); session.setBizModule("visualdev"); session.setBizScene("visualdev.uploadFile"); when(sessionMapper.selectOne(any())).thenReturn(session); UploadEditSessionSupport support = mock(UploadEditSessionSupport.class); java.lang.reflect.Field field = OnlyOfficeServiceImpl.class.getDeclaredField("uploadEditSessionSupport"); field.setAccessible(true); field.set(service, support); UserProvider.setLocalLoginUser(user()); Map status = service.getSaveStatus("lease", "annex"); verify(support).requireStatusAccess("tenant-a", "user-1", "lease"); verify(support, never()).lease(any(), any(), any()); assertFalse(status.containsKey("file")); assertTrue(status.containsKey("sessionStatus")); assertTrue(status.containsKey("lastSaveTime")); assertTrue(status.containsKey("version")); } @Test @SuppressWarnings("unchecked") public void qianpiyeSaveStatusReturnsNewUploadItem() { OnlyOfficeSessionEntity session = session(); session.setBizScene("dms.qianpiye"); session.setQianpiyeSavedFileJson("{\"fileId\":\"saved-copy.docx\",\"name\":\"模板.docx\"}"); when(sessionMapper.selectOne(any())).thenReturn(session); UserProvider.setLocalLoginUser(user()); Map status = service.getSaveStatus("template.docx", "annex"); Map file = (Map) status.get("file"); assertEquals("saved-copy.docx", file.get("fileId")); assertEquals("模板.docx", file.get("name")); } @Test public void missingSaveStatusSessionIsIdle() { when(sessionMapper.selectOne(any())).thenReturn(null); UserProvider.setLocalLoginUser(user()); Map status = service.getSaveStatus("missing.docx", "annex"); assertEquals("IDLE", status.get("sessionStatus")); } @Test public void statusSixCallbackMarksDocumentSavedAndKeepsEditingLease() { OnlyOfficeSessionEntity session = session(); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); session.setLockUserId("user-1"); session.setLockHeartbeat(new Date()); when(sessionMapper.selectOne(any())).thenReturn(session); when(fileUploadApi.replaceFile(any(), eq("WebAnnexFile/"), eq("template.docx"))).thenReturn(true); OnlyOfficeCallbackParam forceSave = callback(); forceSave.setKey("key-1"); forceSave.setStatus(OnlyOfficeCallbackParam.Status.FORCE_SAVING); service.handleCallback(forceSave, callbackToken()); assertTrue(session.getLastSaveTime() != null); assertEquals("user-1", session.getLockUserId()); assertEquals("key-1", session.getDocKey()); assertEquals(Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.EDITING), session.getSessionStatus()); } @Test public void deniedTemplateCallbackDoesNotDownloadAndInvalidatesEditingSession() { OnlyOfficeSessionEntity session = session(); session.setLockUserId("user-1"); session.setLockHeartbeat(new Date()); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); ActionResult denied = probeResult(200, false); when(templateEditabilityApi.editable(any(), any(), any())).thenReturn(denied); try { invokeSaveBack(session, callback(), false); fail("published template callback must be rejected"); } catch (DataException e) { assertEquals("ELN 模板已不可编辑,拒绝写回", e.getMessage()); } assertEquals(0, downloadRequests.get()); verify(fileApi, never()).getPath(any()); verify(fileUploadApi, never()).replaceFile(any(), any(), any()); assertEquals(Integer.valueOf(2), session.getVersionNo()); assertFalse("key-1".equals(session.getDocKey())); assertNull(session.getLockUserId()); assertNull(session.getLockHeartbeat()); assertEquals(Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.IDLE), session.getSessionStatus()); verify(sessionMapper, times(1)).update(isNull(), any()); } @Test public void ordinaryDocumentCallbackDoesNotProbeTemplateAndStillSaves() { OnlyOfficeSessionEntity session = session(); session.setBizModule("lims.document"); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); when(fileUploadApi.replaceFile(any(), eq("WebAnnexFile/"), eq("template.docx"))).thenReturn(true); invokeSaveBack(session, callback(), true); verify(templateEditabilityApi, never()).editable(any(), any(), any()); verify(fileUploadApi).replaceFile(any(), eq("WebAnnexFile/"), eq("template.docx")); } @Test public void failedReplacementDoesNotAdvanceSession() { OnlyOfficeSessionEntity session = session(); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); when(fileUploadApi.replaceFile(any(), eq("WebAnnexFile/"), eq("template.docx"))).thenReturn(false); try { invokeSaveBack(session, callback(), true); fail("expected replacement failure"); } catch (DataException e) { assertEquals("文件服务写回文档失败", e.getMessage()); } assertEquals(Integer.valueOf(1), session.getVersionNo()); assertNull(session.getLastSaveTime()); verify(sessionMapper, never()).updateById(any(OnlyOfficeSessionEntity.class)); } @Test public void viewModeHidesCollaborationMenu() { OnlyOfficeConfigParam param = new OnlyOfficeConfigParam(); param.setFileId("template.docx"); param.setFileName("template.docx"); param.setBizScene("ordinary.view"); UserInfo user = new UserInfo(); user.setUserId("user-1"); user.setUserName("Tester"); Map config = invokeAssembleConfig(param, session(), user); Map document = castMap(config.get("document")); Map permissions = castMap(document.get("permissions")); Map editorConfig = castMap(config.get("editorConfig")); Map customization = castMap(editorConfig.get("customization")); assertEquals(false, permissions.get("comment")); assertEquals(false, permissions.get("review")); assertEquals(false, customization.get("comments")); assertNull(castMap(customization.get("review")).get("reviewDisplay")); } @Test public void editableConfigAllowsDocumentServerSaveWhenDownloadAndPrintAreDisabled() { OnlyOfficeConfigParam param = annotationParam(); Map config = invokeAssembleConfig( param, session(), user(), "edit", plugins(param, "eln-template-annotator")); Map permissions = castMap(castMap(config.get("document")).get("permissions")); // Document Server 9.4 routes the editor Save command through /downloadas and rejects a session // JWT only when both permissions are false. assertEquals(true, permissions.get("edit")); assertFalse(permissions.containsKey("fillForms")); assertFalse(permissions.containsKey("modifyContentControl")); assertEquals(true, permissions.get("download")); assertEquals(false, permissions.get("print")); } @Test public void reviewLeaseOverridesRequestedFullEditAndKeepsNativeSave() { OnlyOfficeConfigParam param = new OnlyOfficeConfigParam(); param.setFileId("review_lease"); param.setFileName("source.docx"); param.setMode("edit"); param.setBizScene(UploadEditSceneConfigBuilder.SCENE); param.setBizModule(UploadEditSceneConfigBuilder.MODULE); OnlyOfficeSceneBuildPlan plan = OnlyOfficeSceneBuildPlan.builder().editAdmission(() -> true) .reviewOnly(true).uploadFileAllowDownload(false).build(); Map config = castMap(invokePrivate("assembleConfig", new Class[] { OnlyOfficeConfigParam.class, OnlyOfficeSessionEntity.class, String.class, String.class, String.class, UserInfo.class, List.class, OnlyOfficeSceneBuildPlan.class, boolean.class }, param, session(), "word", "docx", "edit", user(), Collections.emptyList(), plan, false)); Map permissions = castMap(castMap(config.get("document")).get("permissions")); Map editor = castMap(config.get("editorConfig")); assertEquals("edit", editor.get("mode")); assertEquals(false, permissions.get("edit")); assertEquals(true, permissions.get("review")); assertEquals(true, permissions.get("comment")); assertEquals(Collections.emptyList(), permissions.get("reviewGroups")); assertEquals(true, permissions.get("editCommentAuthorOnly")); assertEquals(false, permissions.get("download")); Map customization = castMap(editor.get("customization")); assertEquals(true, customization.get("forcesave")); assertEquals(true, castMap(customization.get("review")).get("trackChanges")); } @Test public void commentPreviewShowsMarkupWithoutWritableSession() { OnlyOfficeConfigParam param = new OnlyOfficeConfigParam(); param.setFileId("source"); param.setFileName("source.docx"); param.setMode("viewComments"); Map config = invokeAssembleConfig(param, session(), user(), "view", Collections.emptyList()); Map permissions = castMap(castMap(config.get("document")).get("permissions")); Map editor = castMap(config.get("editorConfig")); assertEquals("view", editor.get("mode")); assertEquals(false, permissions.get("edit")); assertEquals(false, permissions.get("review")); assertEquals(true, permissions.get("comment")); assertFalse(editor.containsKey("callbackUrl")); Map customization = castMap(editor.get("customization")); assertEquals(true, customization.get("comments")); assertFalse(customization.containsKey("forcesave")); assertEquals("markup", castMap(customization.get("review")).get("reviewDisplay")); } @Test public void legacyDocIsEditableOnlyInUploadSceneAndStillHonorsAdmission() { OnlyOfficeConfigParam param = new OnlyOfficeConfigParam(); param.setMode("edit"); param.setBizScene(UploadEditSceneConfigBuilder.SCENE); param.setBizModule(UploadEditSceneConfigBuilder.MODULE); OnlyOfficeSessionEntity session = session(); session.setLockUserId(null); session.setLockHeartbeat(null); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE); Class[] signature = {OnlyOfficeConfigParam.class, String.class, OnlyOfficeSessionEntity.class, UserInfo.class, OnlyOfficeSceneBuildPlan.class}; OnlyOfficeSceneBuildPlan allowed = OnlyOfficeSceneBuildPlan.builder().editAdmission(() -> true).build(); assertEquals("edit", invokePrivate("resolveMode", signature, param, "doc", session, user(), allowed)); assertEquals("view", invokePrivate("resolveMode", signature, param, "doc", session, user(), OnlyOfficeSceneBuildPlan.builder().editAdmission(() -> false).build())); param.setBizScene("other.scene"); assertEquals("view", invokePrivate("resolveMode", signature, param, "doc", session, user(), allowed)); } @Test public void uploadEditKeepsDownloadDisabledWithoutChangingNativeSave() { OnlyOfficeConfigParam param = new OnlyOfficeConfigParam(); param.setFileId("lease-1"); param.setFileName("source.docx"); param.setFileType("annex"); param.setBizScene(UploadEditSceneConfigBuilder.SCENE); param.setBizModule(UploadEditSceneConfigBuilder.MODULE); OnlyOfficeSceneBuildPlan plan = OnlyOfficeSceneBuildPlan.builder().editAdmission(() -> true) .sourceFileId("source-file").uploadFileAllowDownload(false).build(); Map config = castMap(invokePrivate("assembleConfig", new Class[] { OnlyOfficeConfigParam.class, OnlyOfficeSessionEntity.class, String.class, String.class, String.class, UserInfo.class, List.class, OnlyOfficeSceneBuildPlan.class, boolean.class }, param, session(), "word", "docx", "edit", user(), Collections.emptyList(), plan, false)); Map permissions = castMap(castMap(config.get("document")).get("permissions")); assertEquals(true, permissions.get("edit")); assertEquals(false, permissions.get("download")); assertEquals(false, permissions.get("print")); assertEquals(true, castMap(castMap(config.get("editorConfig")).get("customization")).get("forcesave")); assertTrue(config.containsKey("token")); } @Test public void workflowPreviewReusesOriginalImageSourceAfterAuthorization() { OnlyOfficeConfigParam param = new OnlyOfficeConfigParam(); param.setFileId("config-ticket"); param.setFileName("source.docx"); param.setFileType("annex"); param.setBizScene(WorkflowFilePreviewConfigBuilder.SCENE); param.setBizModule(WorkflowFilePreviewConfigBuilder.MODULE); OnlyOfficeSceneBuildPlan plan = OnlyOfficeSceneBuildPlan.builder().editAdmission(() -> false) .sourceFileId("source-file").uploadFileAllowDownload(false).build(); Map config = castMap(invokePrivate("assembleConfig", new Class[] { OnlyOfficeConfigParam.class, OnlyOfficeSessionEntity.class, String.class, String.class, String.class, UserInfo.class, List.class, OnlyOfficeSceneBuildPlan.class, boolean.class }, param, session(), "word", "docx", "view", user(), Collections.emptyList(), plan, false)); Map document = castMap(config.get("document")); assertTrue(String.valueOf(document.get("url")).contains("/api/file/Image/annex/source-file?s=")); assertFalse(String.valueOf(document.get("url")).contains("config-ticket")); assertEquals(false, castMap(document.get("permissions")).get("edit")); assertEquals(false, castMap(document.get("permissions")).get("download")); assertTrue(config.containsKey("token")); } @Test public void qianpiyeConfigUsesEditModeAndCarriesTheBusinessConfig() { OnlyOfficeConfigParam param = new OnlyOfficeConfigParam(); param.setFileId("qianpiye.docx"); param.setFileName("qianpiye.docx"); param.setBizScene("dms.qianpiye"); param.setMode("qianpiye"); Map config = invokeAssembleConfig(param, session(), user(), "edit", Collections.emptyList()); Map qianpiye = castMap(config.get("dms.qianpiye")); Map permissions = castMap(castMap(config.get("document")).get("permissions")); Map customization = castMap(castMap(config.get("editorConfig")).get("customization")); assertEquals("edit", castMap(config.get("editorConfig")).get("mode")); assertEquals(true, qianpiye.get("enabled")); assertEquals("qianpiye", qianpiye.get("mode")); assertEquals(false, permissions.get("download")); assertEquals(false, permissions.get("print")); assertEquals(true, customization.get("plugins")); assertEquals(false, customization.get("autosave")); assertEquals(false, customization.get("forcesave")); } @Test public void recordFillKeepsEditorCommandsEnabledForTheControlledFiller() { OnlyOfficeConfigParam param = recordParam(); Map config = invokeAssembleConfig( param, recordSession(), user(), "edit", plugins(param, "eln-record-filler")); Map permissions = castMap(castMap(config.get("document")).get("permissions")); Map editorConfig = castMap(config.get("editorConfig")); Map context = pluginContext(config, fillGuid()); assertEquals("edit", editorConfig.get("mode")); assertEquals(true, permissions.get("edit")); assertFalse(permissions.containsKey("fillForms")); assertFalse(permissions.containsKey("modifyContentControl")); assertEquals(false, permissions.get("comment")); assertEquals(false, permissions.get("review")); assertEquals(true, context.get("canFill")); } @Test public void recordFillReopenByLockOwnerTakesOverWithANewEditableLease() { OnlyOfficeConfigParam param = recordParam(); OnlyOfficeSessionEntity session = recordSession(); String previousDocKey = session.getDocKey(); ActionResult allowed = probeResult(200, true); when(recordFillApi.canFill(any(), any(), any(), any())).thenReturn(allowed); when(sessionMapper.selectOne(any())).thenReturn(session); when(sessionMapper.update(any(OnlyOfficeSessionEntity.class), any())).thenReturn(1); when(pluginResolver.resolve(param)).thenReturn(plugins(param, "eln-record-filler")); UserProvider.setLocalLoginUser(user()); Map config = service.buildEditorConfig(param); Map editorConfig = castMap(config.get("editorConfig")); Map context = pluginContext(config, fillGuid()); assertEquals("edit", editorConfig.get("mode")); assertEquals(true, context.get("canFill")); assertEquals("user-1", session.getLockUserId()); assertEquals(Integer.valueOf(42), session.getVersion()); assertNotEquals(previousDocKey, session.getDocKey()); when(sessionMapper.selectOne(any())).thenReturn(null); OnlyOfficeCallbackParam replacedLeaseCallback = callback(); replacedLeaseCallback.setKey(previousDocKey); try { service.handleCallback(replacedLeaseCallback, callbackToken()); fail("callback from the replaced lease must be fenced"); } catch (DataException expected) { assertTrue(expected.getMessage().contains(previousDocKey)); } verify(fileUploadApi, never()).replaceFile(any(), any(), any()); } @Test public void recordFillOpenByAnotherUserKeepsTheExistingLeaseReadOnly() { OnlyOfficeConfigParam param = recordParam(); OnlyOfficeSessionEntity session = recordSession(); String existingDocKey = session.getDocKey(); ActionResult allowed = probeResult(200, true); when(recordFillApi.canFill(any(), any(), any(), any())).thenReturn(allowed); when(sessionMapper.selectOne(any())).thenReturn(session); when(pluginResolver.resolve(param)).thenReturn(plugins(param, "eln-record-filler")); UserProvider.setLocalLoginUser(user("user-2")); Map config = service.buildEditorConfig(param); Map editorConfig = castMap(config.get("editorConfig")); Map context = pluginContext(config, fillGuid()); assertEquals("view", editorConfig.get("mode")); assertEquals(false, context.get("canFill")); assertEquals("user-1", session.getLockUserId()); assertEquals(Integer.valueOf(41), session.getVersion()); assertEquals(existingDocKey, session.getDocKey()); verify(sessionMapper, never()).update(any(OnlyOfficeSessionEntity.class), any()); } @Test public void annotateTargetReceivesTicketAndRepairHint() { OnlyOfficeConfigParam param = annotationParam(); param.setRepairBindingId("binding-1"); when(annotateTicketIssuer.issue(any(), any(), eq(annotationGuid()))).thenReturn("annotation-ticket"); Map context = pluginContext(invokeAssembleConfig( param, session(), user(), "edit", plugins(param, "eln-template-annotator")), annotationGuid()); assertEquals("onlyoffice:eln-template-annotate", context.get("scope")); assertEquals("annotation-ticket", context.get("ticket")); assertEquals(true, context.get("canAnnotate")); assertEquals("binding-1", context.get("repairBindingId")); } @Test public void annotationSceneDoesNotEnterReadOnlyFinalRevisionView() { OnlyOfficeConfigParam param = annotationParam(); Map config = invokeAssembleConfig( param, session(), user(), "edit", plugins(param, "eln-template-annotator")); Map editorConfig = castMap(config.get("editorConfig")); Map customization = castMap(editorConfig.get("customization")); assertNull(castMap(customization.get("review")).get("reviewDisplay")); } @Test public void ordinaryReadOnlyAnnotationDoesNotEnableAnnotator() { OnlyOfficeConfigParam param = annotationParam(); Map config = invokeAssembleConfig( param, session(), user(), "view", plugins(param, "eln-template-annotator")); Map editorConfig = castMap(config.get("editorConfig")); assertFalse(editorConfig.containsKey("plugins")); verify(annotateTicketIssuer, never()).issue(any(), any(), any()); } @Test public void failedAnnotationTicketDoesNotAcquireEditLock() { OnlyOfficeConfigParam param = annotationParam(); param.setFileType("annex"); param.setMode("edit"); OnlyOfficeSessionEntity session = session(); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE); when(sessionMapper.selectOne(any())).thenReturn(session); when(sessionMapper.update(any(OnlyOfficeSessionEntity.class), any())).thenReturn(1); when(sessionMapper.update(isNull(), any())).thenReturn(1); when(pluginResolver.resolve(param)).thenReturn(plugins(param, "eln-template-annotator")); when(annotateTicketIssuer.issue(any(), any(), any())).thenThrow(new IllegalArgumentException("invalid")); UserProvider.setLocalLoginUser(user()); try { service.buildEditorConfig(param); fail("expected annotation ticket failure"); } catch (IllegalArgumentException e) { assertEquals("invalid", e.getMessage()); } assertNull(session.getLockUserId()); assertNull(session.getLockHeartbeat()); assertEquals(Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.IDLE), session.getSessionStatus()); } @Test public void failedConfigCompensationDoesNotReleaseNewerLockFromExpiredOtherUser() throws Exception { OnlyOfficeConfigParam param = annotationParam(); param.setMode("edit"); OnlyOfficeSessionEntity firstSnapshot = session(); firstSnapshot.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE); firstSnapshot.setVersionNo(17); firstSnapshot.setVersion(40); OnlyOfficeSessionEntity secondSnapshot = session(); secondSnapshot.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); secondSnapshot.setLockUserId("user-2"); secondSnapshot.setLockHeartbeat(new Date(0)); secondSnapshot.setVersionNo(17); secondSnapshot.setVersion(41); AtomicReference persistedLockUser = new AtomicReference<>(); AtomicInteger persistedLockVersion = new AtomicInteger(40); CountDownLatch firstTicketStarted = new CountDownLatch(1); CountDownLatch allowFirstTicketFailure = new CountDownLatch(1); when(pluginResolver.resolve(param)).thenReturn(plugins(param, "eln-template-annotator")); when(sessionMapper.selectOne(any())).thenAnswer(invocation -> Thread.currentThread().getName().equals("config-a") ? firstSnapshot : secondSnapshot); when(sessionMapper.update(any(OnlyOfficeSessionEntity.class), any())).thenAnswer(invocation -> { OnlyOfficeSessionEntity update = invocation.getArgument(0); Wrapper wrapper = invocation.getArgument(1); assertTrue(wrapper.getSqlSegment().contains("f_version")); int acquiredVersion = update.getVersion(); if (!persistedLockVersion.compareAndSet(acquiredVersion - 1, acquiredVersion)) { return 0; } persistedLockUser.set(update.getLockUserId()); return 1; }); when(sessionMapper.update(isNull(), any())).thenAnswer(invocation -> { UpdateWrapper wrapper = invocation.getArgument(1); String sqlSet = wrapper.getSqlSet(); if (sqlSet.contains("file_name") && sqlSet.contains("document_type")) { return 1; } String sql = wrapper.getSqlSegment(); assertTrue(sql.contains("lock_user_id")); assertTrue(sql.contains("f_version")); assertTrue(sql.contains("session_status")); assertTrue(((AbstractWrapper) wrapper).getParamNameValuePairs() .containsValue(OnlyOfficeSessionEntity.SessionStatus.EDITING)); if (((AbstractWrapper) wrapper).getParamNameValuePairs() .containsValue(persistedLockVersion.get())) { persistedLockUser.set(null); return 1; } return 0; }); when(sessionMapper.updateById(any(OnlyOfficeSessionEntity.class))).thenAnswer(invocation -> { OnlyOfficeSessionEntity update = invocation.getArgument(0); persistedLockUser.set(update.getLockUserId()); return 1; }); when(annotateTicketIssuer.issue(any(), any(), any())).thenAnswer(invocation -> { if (Thread.currentThread().getName().equals("config-a")) { firstTicketStarted.countDown(); assertTrue(allowFirstTicketFailure.await(5, TimeUnit.SECONDS)); throw new IllegalArgumentException("config-a failed"); } return "annotation-ticket-b"; }); ExecutorService firstExecutor = Executors.newSingleThreadExecutor( task -> new Thread(task, "config-a")); ExecutorService secondExecutor = Executors.newSingleThreadExecutor( task -> new Thread(task, "config-b")); try { Future> first = firstExecutor.submit(() -> { UserProvider.setLocalLoginUser(user()); try { return service.buildEditorConfig(param); } finally { UserProvider.clearLocalUser(); } }); assertTrue(firstTicketStarted.await(5, TimeUnit.SECONDS)); Future> second = secondExecutor.submit(() -> { UserProvider.setLocalLoginUser(user("user-2")); try { return service.buildEditorConfig(param); } finally { UserProvider.clearLocalUser(); } }); assertEquals("edit", castMap(second.get(5, TimeUnit.SECONDS) .get("editorConfig")).get("mode")); allowFirstTicketFailure.countDown(); try { first.get(5, TimeUnit.SECONDS); fail("expected first config assembly failure"); } catch (ExecutionException e) { assertEquals("config-a failed", e.getCause().getMessage()); } assertEquals("user-2", persistedLockUser.get()); assertEquals("user-1", firstSnapshot.getLockUserId()); assertEquals(Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.EDITING), firstSnapshot.getSessionStatus()); } finally { allowFirstTicketFailure.countDown(); firstExecutor.shutdownNow(); secondExecutor.shutdownNow(); } } @Test public void expiredSameUserReacquireGetsNewLeaseKeyAndOldCallbackCannotReleaseIt() { OnlyOfficeConfigParam param = annotationParam(); param.setMode("edit"); OnlyOfficeSessionEntity current = session(); current.setDocKey("lease-a"); current.setVersion(40); current.setLockUserId("user-1"); current.setLockHeartbeat(new Date(0)); current.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); when(pluginResolver.resolve(param)).thenReturn(plugins(param, "eln-template-annotator")); when(annotateTicketIssuer.issue(any(), any(), any())).thenReturn("annotation-ticket"); when(sessionMapper.selectOne(any())).thenAnswer(invocation -> { Wrapper query = invocation.getArgument(0); return query.getSqlSegment().contains("doc_key") && !"lease-a".equals(current.getDocKey()) ? null : current; }); when(sessionMapper.update(any(OnlyOfficeSessionEntity.class), any())).thenReturn(1); UserProvider.setLocalLoginUser(user()); Map config = service.buildEditorConfig(param); String currentLeaseKey = (String) castMap(config.get("document")).get("key"); assertNotEquals("lease-a", currentLeaseKey); assertEquals(currentLeaseKey, current.getDocKey()); OnlyOfficeCallbackParam oldClose = callback(); oldClose.setKey("lease-a"); oldClose.setStatus(OnlyOfficeCallbackParam.Status.CLOSED_NO_CHANGES); try { service.handleCallback(oldClose, callbackToken()); fail("old lease callback must not find the current session"); } catch (DataException e) { assertTrue(e.getMessage().contains("找不到 doc_key")); } assertEquals("user-1", current.getLockUserId()); assertEquals(Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.EDITING), current.getSessionStatus()); verify(sessionMapper, never()).updateById(any(OnlyOfficeSessionEntity.class)); } @Test public void staleHeartbeatAndReleaseSnapshotsCannotOverwriteNewerLease() { OnlyOfficeSessionEntity stale = session(); stale.setDocKey("lease-a"); stale.setVersion(41); stale.setLockUserId("user-1"); stale.setLockHeartbeat(new Date()); stale.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); OnlyOfficeSessionEntity persistedNewerLease = session(); persistedNewerLease.setDocKey("lease-b"); persistedNewerLease.setVersion(42); persistedNewerLease.setLockUserId("user-2"); persistedNewerLease.setLockHeartbeat(new Date()); persistedNewerLease.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); AtomicInteger casAttempts = new AtomicInteger(); when(sessionMapper.update(isNull(), any())).thenAnswer(invocation -> { UpdateWrapper update = invocation.getArgument(1); AbstractWrapper wrapper = (AbstractWrapper) update; String sql = wrapper.getSqlSegment(); assertTrue(sql.contains("f_id") && sql.contains("doc_key") && sql.contains("version_no") && sql.contains("f_version") && sql.contains("session_status")); casAttempts.incrementAndGet(); return 0; }); invokePrivate("refreshLockHeartbeat", new Class[]{OnlyOfficeSessionEntity.class}, stale); invokePrivate("releaseLock", new Class[]{OnlyOfficeSessionEntity.class, int.class}, stale, OnlyOfficeSessionEntity.SessionStatus.IDLE); assertEquals(2, casAttempts.get()); assertEquals("lease-b", persistedNewerLease.getDocKey()); assertEquals(Integer.valueOf(42), persistedNewerLease.getVersion()); assertEquals("user-2", persistedNewerLease.getLockUserId()); assertEquals(Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.EDITING), persistedNewerLease.getSessionStatus()); verify(sessionMapper, never()).updateById(any(OnlyOfficeSessionEntity.class)); } @Test public void saveDeclaresSavingBeforeExternalWriteAndBlocksCompetingAcquire() { OnlyOfficeSessionEntity savingSession = session(); savingSession.setDocKey("lease-a"); savingSession.setVersion(41); savingSession.setLockUserId("user-1"); savingSession.setLockHeartbeat(new Date()); savingSession.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); AtomicBoolean savingDeclared = new AtomicBoolean(); when(sessionMapper.update(org.mockito.ArgumentMatchers.nullable(OnlyOfficeSessionEntity.class), any())) .thenAnswer(invocation -> { OnlyOfficeSessionEntity updateEntity = invocation.getArgument(0); UpdateWrapper update = invocation.getArgument(1); if (updateEntity == null && update != null && update.getSqlSet().contains("session_status")) { savingDeclared.set(true); return 1; } assertTrue("save callback must claim SAVING before external write", savingDeclared.get()); AbstractWrapper acquire = (AbstractWrapper) update; assertEquals(3, countOccurrences(acquire.getSqlSegment(), "session_status")); assertTrue(acquire.getParamNameValuePairs() .containsValue(OnlyOfficeSessionEntity.SessionStatus.IDLE)); assertTrue(acquire.getParamNameValuePairs() .containsValue(OnlyOfficeSessionEntity.SessionStatus.EDITING)); assertFalse(acquire.getParamNameValuePairs().containsValue(SAVING_STATUS)); assertFalse(acquire.getParamNameValuePairs() .containsValue(OnlyOfficeSessionEntity.SessionStatus.PUBLISHING)); assertFalse(acquire.getParamNameValuePairs() .containsValue(OnlyOfficeSessionEntity.SessionStatus.SAVE_FAILED)); return 0; }); when(fileUploadApi.replaceFile(any(), eq("WebAnnexFile/"), eq("template.docx"))).thenAnswer(invocation -> { OnlyOfficeSessionEntity competing = session(); competing.setDocKey("lease-a"); competing.setVersion(41); competing.setLockUserId("user-1"); competing.setLockHeartbeat(new Date()); competing.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); try { invokePrivate("acquireLock", new Class[]{OnlyOfficeSessionEntity.class, String.class}, competing, "user-2"); fail("competing acquire must fail while the save owns SAVING"); } catch (DataException expected) { assertEquals("文档编辑锁获取失败,请重新打开", expected.getMessage()); } return true; }); try { invokeSaveBack(savingSession, callback(), true); } catch (DataException e) { assertTrue(e.getMessage().contains("save callback must claim SAVING")); fail("save callback must declare SAVING before external write"); } assertTrue(savingDeclared.get()); verify(sessionMapper, never()).updateById(any(OnlyOfficeSessionEntity.class)); } @Test public void claimSaveRefreshesHeartbeatAndMarksTheLeaseSaving() { OnlyOfficeSessionEntity session = session(); session.setDocKey("lease-a"); session.setVersion(41); session.setLockUserId("user-1"); session.setLockHeartbeat(new Date(1)); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); AtomicReference> claim = new AtomicReference<>(); when(sessionMapper.update(isNull(), any())).thenAnswer(invocation -> { claim.set(invocation.getArgument(1)); return 1; }); assertEquals(true, invokePrivate("claimSave", new Class[]{OnlyOfficeSessionEntity.class}, session)); assertTrue(claim.get().getSqlSet().contains("session_status")); assertTrue(claim.get().getSqlSet().contains("lock_heartbeat")); assertTrue(session.getLockHeartbeat().after(new Date(1))); assertEquals(Integer.valueOf(SAVING_STATUS), session.getSessionStatus()); } @Test public void freshSavingLeaseRejectsSameUserAcquire() { OnlyOfficeSessionEntity freshSaving = session(); freshSaving.setDocKey("lease-fresh"); freshSaving.setVersion(41); freshSaving.setLockUserId("user-1"); freshSaving.setLockHeartbeat(new Date()); freshSaving.setSessionStatus(SAVING_STATUS); when(sessionMapper.update(any(OnlyOfficeSessionEntity.class), any())).thenReturn(0); try { invokePrivate("acquireLock", new Class[]{OnlyOfficeSessionEntity.class, String.class}, freshSaving, "user-1"); fail("fresh SAVING lease must reject same-user acquire"); } catch (DataException e) { assertEquals("文档编辑锁获取失败,请重新打开", e.getMessage()); } } @Test public void expiredSavingLeaseCannotBeTakenOver() { OnlyOfficeSessionEntity expiredSaving = session(); expiredSaving.setDocKey("lease-expired"); expiredSaving.setVersion(41); expiredSaving.setLockUserId("user-1"); expiredSaving.setLockHeartbeat(new Date(0)); expiredSaving.setSessionStatus(SAVING_STATUS); try { invokePrivate("acquireLock", new Class[]{OnlyOfficeSessionEntity.class, String.class}, expiredSaving, "user-2"); fail("expired SAVING lease must not be taken over automatically"); } catch (DataException e) { assertEquals("文档编辑锁获取失败,请重新打开", e.getMessage()); } verify(sessionMapper, never()).update(any(OnlyOfficeSessionEntity.class), any()); } @Test public void completedFileWriteWithFailedSessionCasRaisesRecoveryError() { OnlyOfficeSessionEntity session = session(); session.setDocKey("lease-a"); session.setVersion(41); session.setLockUserId("user-1"); session.setLockHeartbeat(new Date()); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); AtomicInteger sessionWrites = new AtomicInteger(); when(sessionMapper.update(isNull(), any())).thenAnswer(invocation -> sessionWrites.incrementAndGet() == 1 ? 1 : 0); when(fileUploadApi.replaceFile(any(), eq("WebAnnexFile/"), eq("template.docx"))).thenReturn(true); try { invokeSaveBack(session, callback(), true); fail("completed file write with failed session CAS must not be acknowledged as success"); } catch (DataException e) { assertEquals("文件已写回,但 OnlyOffice 会话状态更新失败,请人工确认后重试", e.getMessage()); } assertEquals(2, sessionWrites.get()); assertEquals(Integer.valueOf(SAVING_STATUS), session.getSessionStatus()); assertEquals("lease-a", session.getDocKey()); } @Test public void forceSaveErrorKeepsTheEditingLeaseActive() { OnlyOfficeSessionEntity session = session(); session.setDocKey("lease-a"); session.setVersion(41); session.setLockUserId("user-1"); session.setLockHeartbeat(new Date()); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); when(sessionMapper.selectOne(any())).thenReturn(session); OnlyOfficeCallbackParam forceSaveError = callback(); forceSaveError.setKey("lease-a"); forceSaveError.setStatus(OnlyOfficeCallbackParam.Status.FORCE_SAVE_ERROR); service.handleCallback(forceSaveError, callbackToken()); assertEquals("user-1", session.getLockUserId()); assertEquals(Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.EDITING), session.getSessionStatus()); verify(sessionMapper, never()).update(isNull(), any()); } @Test public void sameUserFreshLeaseReturnsViewWhileExistingTabCanStillSave() { OnlyOfficeConfigParam param = annotationParam(); param.setMode("edit"); OnlyOfficeSessionEntity existingLease = session(); existingLease.setDocKey("lease-a"); existingLease.setVersion(41); existingLease.setLockUserId("user-1"); existingLease.setLockHeartbeat(new Date()); existingLease.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); when(sessionMapper.selectOne(any())).thenReturn(existingLease); when(pluginResolver.resolve(param)).thenReturn(plugins(param, "eln-template-annotator")); when(annotateTicketIssuer.issue(any(), any(), any())).thenReturn("annotation-ticket"); when(fileUploadApi.replaceFile(any(), eq("WebAnnexFile/"), eq("template.docx"))).thenReturn(true); UserProvider.setLocalLoginUser(user()); Map secondTab = service.buildEditorConfig(param); assertEquals("view", castMap(secondTab.get("editorConfig")).get("mode")); assertEquals("lease-a", castMap(secondTab.get("document")).get("key")); verify(sessionMapper, never()).update(any(OnlyOfficeSessionEntity.class), any()); invokeSaveBack(existingLease, callback(), true); assertEquals(Integer.valueOf(2), existingLease.getVersionNo()); assertEquals(Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.IDLE), existingLease.getSessionStatus()); } @Test public void sameLeaseSavingCallbackRaisesErrorInsteadOfBeingSilentlyIgnored() { OnlyOfficeSessionEntity savingLease = session(); savingLease.setDocKey("lease-a"); savingLease.setVersion(41); savingLease.setLockUserId("user-1"); savingLease.setLockHeartbeat(new Date()); savingLease.setSessionStatus(SAVING_STATUS); when(sessionMapper.selectOne(any())).thenReturn(savingLease, savingLease); when(sessionMapper.update(isNull(), any())).thenReturn(0); OnlyOfficeCallbackParam forceSave = callback(); forceSave.setKey("lease-a"); forceSave.setStatus(OnlyOfficeCallbackParam.Status.FORCE_SAVING); try { service.handleCallback(forceSave, callbackToken()); fail("same lease SAVING callback must not be silently acknowledged as stale"); } catch (DataException e) { assertEquals("OnlyOffice 同一租约保存回调正在处理", e.getMessage()); } verify(fileUploadApi, never()).replaceFile(any(), any(), any()); } @Test public void templateProbeFalseOrFailureReturnsViewWithoutAcquiringLock() { assertProbeDeniedReturnsView(probeResult(200, false), null); assertProbeDeniedReturnsView(probeResult(500, true), null); assertProbeDeniedReturnsView(null, null); assertProbeDeniedReturnsView(null, new IllegalStateException("eln unavailable")); } @Test public void successfulTemplateProbeReturnsEditAndAcquiresLock() { OnlyOfficeConfigParam param = annotationParam(); param.setMode("edit"); OnlyOfficeSessionEntity session = session(); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE); when(sessionMapper.selectOne(any())).thenReturn(session); when(sessionMapper.update(any(OnlyOfficeSessionEntity.class), any())).thenReturn(1); when(pluginResolver.resolve(param)).thenReturn(plugins(param, "eln-template-annotator")); when(annotateTicketIssuer.issue(any(), any(), any())).thenReturn("annotation-ticket"); UserProvider.setLocalLoginUser(user()); Map config = service.buildEditorConfig(param); assertEquals("edit", castMap(config.get("editorConfig")).get("mode")); assertEquals("user-1", session.getLockUserId()); verify(templateEditabilityApi).canAnnotate( "tenant-a", "user-1", "template-1", "template.docx"); verify(templateEditabilityApi, never()).editable(any(), any(), any()); } @Test public void existingElnSessionIdentityCannotBeOverwrittenByAnotherBusinessContext() { OnlyOfficeConfigParam param = param("ordinary.edit", "document-helper"); param.setBizModule("lims.document"); param.setBizDataId("other-record"); param.setMode("edit"); OnlyOfficeSessionEntity session = session(); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE); when(sessionMapper.selectOne(any())).thenReturn(session); when(pluginResolver.resolve(param)).thenReturn(plugins(param, "document-helper")); UserProvider.setLocalLoginUser(user()); try { service.buildEditorConfig(param); fail("existing ELN session identity must be immutable"); } catch (DataException e) { assertEquals("OnlyOffice 会话业务上下文不匹配", e.getMessage()); } assertEquals("tenant-a", session.getTenantId()); assertEquals("eln.template", session.getBizModule()); assertEquals("template-1", session.getBizDataId()); assertNull(session.getLockUserId()); verify(sessionMapper, never()).update(any(), any()); } @Test public void elnSessionGuardRejectsForgedFileTypeBeforeGenericLookupOrLock() { OnlyOfficeConfigParam param = param("ordinary.edit", "document-helper"); param.setBizModule("lims.document"); param.setBizDataId("other-record"); param.setFileType("web"); param.setMode("edit"); OnlyOfficeSessionEntity session = session(); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE); when(sessionMapper.selectOne(any())).thenAnswer(invocation -> { Wrapper query = invocation.getArgument(0); return query.getSqlSegment().contains("biz_module") ? session : null; }); when(pluginResolver.resolve(param)).thenReturn(plugins(param, "document-helper")); UserProvider.setLocalLoginUser(user()); try { service.buildEditorConfig(param); fail("forged file type must be rejected by ELN session guard"); } catch (DataException e) { assertEquals("OnlyOffice 会话业务上下文不匹配", e.getMessage()); } verify(sessionMapper).selectOne(argThat(query -> { String sql = query.getSqlSegment(); return sql.contains("file_id") && sql.contains("biz_module") && !sql.contains("file_type"); })); verify(sessionMapper, never()).insert(any(OnlyOfficeSessionEntity.class)); verify(sessionMapper, never()).update(any(OnlyOfficeSessionEntity.class), any()); verify(sessionMapper, never()).update(isNull(), any()); assertNull(session.getLockUserId()); } @Test public void sessionLookupsAreTenantScopedAcrossElnGuardAndGenericFileLookup() { OnlyOfficeConfigParam param = annotationParam(); OnlyOfficeSessionEntity otherTenant = session(); otherTenant.setTenantId("tenant-b"); AtomicInteger tenantScopedQueries = new AtomicInteger(); when(sessionMapper.selectOne(any())).thenAnswer(invocation -> { AbstractWrapper query = invocation.getArgument(0); query.getSqlSegment(); if (!query.getParamNameValuePairs().containsValue("tenant-a")) { return otherTenant; } tenantScopedQueries.incrementAndGet(); return null; }); OnlyOfficeSessionEntity created = (OnlyOfficeSessionEntity) invokePrivate( "loadOrCreateSession", new Class[]{OnlyOfficeConfigParam.class, String.class, String.class}, param, "word", "tenant-a"); assertEquals("tenant-a", created.getTenantId()); assertEquals(3, tenantScopedQueries.get()); verify(sessionMapper).selectOne(argThat(query -> { String sql = query.getSqlSegment(); return sql.contains("file_id") && sql.contains("biz_module") && sql.contains("f_tenant_id") && !sql.contains("file_type") && ((AbstractWrapper) query).getParamNameValuePairs() .containsValue("tenant-a") && ((AbstractWrapper) query).getParamNameValuePairs() .containsValue("eln.template"); })); verify(sessionMapper).selectOne(argThat(query -> { String sql = query.getSqlSegment(); return sql.contains("file_id") && sql.contains("biz_module") && sql.contains("f_tenant_id") && !sql.contains("file_type") && ((AbstractWrapper) query).getParamNameValuePairs() .containsValue("tenant-a") && ((AbstractWrapper) query).getParamNameValuePairs() .containsValue("eln.record"); })); verify(sessionMapper).selectOne(argThat(query -> { String sql = query.getSqlSegment(); return sql.contains("file_id") && sql.contains("file_type") && sql.contains("f_tenant_id") && ((AbstractWrapper) query).getParamNameValuePairs() .containsValue("tenant-a"); })); } @Test public void deniedRecordProbeReturnsViewWithoutLockOrFillTicket() { OnlyOfficeConfigParam param = recordParam(); param.setMode("edit"); OnlyOfficeSessionEntity session = recordSession(); session.setLockUserId(null); session.setLockHeartbeat(null); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE); when(sessionMapper.selectOne(any())).thenReturn(session); when(pluginResolver.resolve(param)).thenReturn(plugins(param, "eln-record-filler")); ActionResult denied = probeResult(200, false); when(recordFillApi.canFill(any(), any(), any(), any())).thenReturn(denied); UserProvider.setLocalLoginUser(user()); Map config = service.buildEditorConfig(param); assertEquals("view", castMap(config.get("editorConfig")).get("mode")); assertNull(session.getLockUserId()); verify(recordFillApi).canFill("tenant-a", "user-1", "record-1", "template.docx"); verify(recordFillTicketIssuer).issue(any(), any(), any()); } @Test public void otherSceneUsesFallbackPermissionWithoutTemplateProbe() { OnlyOfficeConfigParam param = param("other.scene", "document-helper"); param.setFileType("annex"); param.setMode("edit"); OnlyOfficeSessionEntity session = session(); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE); when(sessionMapper.selectOne(any())).thenReturn(session); when(pluginResolver.resolve(param)).thenReturn(plugins(param, "document-helper")); when(sessionMapper.update(any(OnlyOfficeSessionEntity.class), any())).thenReturn(1); UserProvider.setLocalLoginUser(user()); Map config = service.buildEditorConfig(param); assertEquals("edit", castMap(config.get("editorConfig")).get("mode")); assertEquals("user-1", session.getLockUserId()); verify(templateEditabilityApi, never()).canAnnotate(any(), any(), any(), any()); verify(annotateTicketIssuer, never()).issue(any(), any(), any()); } @Test public void editableRecordProbeAllowsFillOnlyWhenIdentityMatches() { OnlyOfficeConfigParam param = recordParam(); param.setMode("edit"); OnlyOfficeSessionEntity session = recordSession(); session.setLockUserId(null); session.setLockHeartbeat(null); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE); when(sessionMapper.selectOne(any())).thenReturn(session); when(sessionMapper.update(any(OnlyOfficeSessionEntity.class), any())).thenReturn(1); when(pluginResolver.resolve(param)).thenReturn(plugins(param, "eln-record-filler")); ActionResult allowed = probeResult(200, true); when(recordFillApi.canFill(any(), any(), any(), any())).thenReturn(allowed); UserProvider.setLocalLoginUser(user()); Map config = service.buildEditorConfig(param); assertEquals("edit", castMap(config.get("editorConfig")).get("mode")); assertEquals("user-1", session.getLockUserId()); verify(recordFillApi).canFill("tenant-a", "user-1", "record-1", "template.docx"); verify(recordFillTicketIssuer).issue(any(), any(), any()); } @Test public void legacyElnSessionWithoutTenantIsRejectedBeforeSessionUpdate() { OnlyOfficeConfigParam param = annotationParam(); param.setMode("edit"); OnlyOfficeSessionEntity session = session(); session.setTenantId(null); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE); when(sessionMapper.selectOne(any())).thenReturn(session); when(pluginResolver.resolve(param)).thenReturn(plugins(param, "eln-template-annotator")); UserProvider.setLocalLoginUser(user()); try { service.buildEditorConfig(param); fail("ELN session without tenant must be rejected"); } catch (DataException e) { assertEquals("OnlyOffice 会话业务上下文不匹配", e.getMessage()); } assertNull(session.getLockUserId()); verify(sessionMapper, never()).update(isNull(), any()); } @Test public void ordinaryConfigDoesNotProbeTemplate() { OnlyOfficeConfigParam param = param("ordinary.edit", "document-helper"); param.setBizModule("lims.document"); OnlyOfficeSessionEntity session = session(); session.setBizModule("lims.document"); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE); when(sessionMapper.selectOne(any())).thenReturn(session); when(sessionMapper.update(any(OnlyOfficeSessionEntity.class), any())).thenReturn(1); when(pluginResolver.resolve(param)).thenReturn(plugins(param, "document-helper")); UserProvider.setLocalLoginUser(user()); Map config = service.buildEditorConfig(param); assertEquals("edit", castMap(config.get("editorConfig")).get("mode")); verify(templateEditabilityApi, never()).editable(any(), any(), any()); verify(templateEditabilityApi, never()).canAnnotate(any(), any(), any(), any()); } @Test public void newSessionPersistsTenantIdExplicitly() { OnlyOfficeConfigParam param = annotationParam(); when(pluginResolver.resolve(param)).thenReturn(plugins(param, "eln-template-annotator")); when(annotateTicketIssuer.issue(any(), any(), any())).thenReturn("annotation-ticket"); when(sessionMapper.update(any(OnlyOfficeSessionEntity.class), any())).thenReturn(1); UserProvider.setLocalLoginUser(user()); service.buildEditorConfig(param); verify(sessionMapper).insert(argThat((OnlyOfficeSessionEntity session) -> "tenant-a".equals(session.getTenantId()))); } @Test public void publishingSessionForcesReadOnlyConfig() { OnlyOfficeConfigParam param = annotationParam(); param.setMode("edit"); OnlyOfficeSessionEntity session = session(); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.PUBLISHING); session.setPublishToken("publish-token"); when(sessionMapper.selectOne(any())).thenReturn(session); when(pluginResolver.resolve(param)).thenReturn(plugins(param, "eln-template-annotator")); UserProvider.setLocalLoginUser(user()); Map config = service.buildEditorConfig(param); assertEquals("view", castMap(config.get("editorConfig")).get("mode")); verify(sessionMapper, never()).update(any(OnlyOfficeSessionEntity.class), any()); } @Test public void publishingSessionCannotBeTakenByEditorLock() { OnlyOfficeSessionEntity session = session(); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.PUBLISHING); session.setPublishToken("publish-token"); try { invokePrivate("acquireLock", new Class[]{OnlyOfficeSessionEntity.class, String.class}, session, "user-1"); fail("PUBLISHING session must reject editor acquire"); } catch (DataException e) { assertEquals("文档编辑锁获取失败,请重新打开", e.getMessage()); } verify(sessionMapper, never()).update(any(OnlyOfficeSessionEntity.class), any()); } @Test public void retiringAndHistoricalRecordSessionsCannotBeTakenByEditorLock() { int[] blocked = { OnlyOfficeSessionEntity.SessionStatus.RECORD_RETIRING, OnlyOfficeSessionEntity.SessionStatus.RECORD_HISTORICAL }; for (int status : blocked) { OnlyOfficeSessionEntity session = recordSession(); session.setSessionStatus(status); session.setPublishToken(status == OnlyOfficeSessionEntity.SessionStatus.RECORD_RETIRING ? "revision-token" : null); try { invokePrivate("acquireLock", new Class[]{OnlyOfficeSessionEntity.class, String.class}, session, "user-1"); fail("retiring or historical record session must reject editor acquire"); } catch (DataException e) { assertEquals("历史记录文档不可编辑", e.getMessage()); } } verify(sessionMapper, never()).update(any(OnlyOfficeSessionEntity.class), any()); } @Test public void retiringAndHistoricalRecordSessionsResolveToReadOnly() { ActionResult allowed = probeResult(200, true); when(recordFillApi.canFill(any(), any(), any(), any())).thenReturn(allowed); OnlyOfficeConfigParam param = recordParam(); int[] blocked = { OnlyOfficeSessionEntity.SessionStatus.RECORD_RETIRING, OnlyOfficeSessionEntity.SessionStatus.RECORD_HISTORICAL }; for (int status : blocked) { OnlyOfficeSessionEntity session = recordSession(); session.setSessionStatus(status); session.setLockUserId(null); session.setLockHeartbeat(null); assertEquals("view", invokePrivate("resolveMode", new Class[]{OnlyOfficeConfigParam.class, String.class, OnlyOfficeSessionEntity.class, UserInfo.class}, param, "docx", session, user())); } } @Test public void concurrentFirstSessionInsertReloadsPublishingFenceAsView() { OnlyOfficeConfigParam param = annotationParam(); param.setMode("edit"); OnlyOfficeSessionEntity publishing = session(); publishing.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.PUBLISHING); publishing.setPublishToken("publish-token"); when(sessionMapper.selectOne(any())).thenReturn(null, null, publishing); when(sessionMapper.insert(any(OnlyOfficeSessionEntity.class))) .thenThrow(new DuplicateKeyException("duplicate file identity")); when(pluginResolver.resolve(param)).thenReturn(plugins(param, "eln-template-annotator")); UserProvider.setLocalLoginUser(user()); Map config = service.buildEditorConfig(param); assertEquals("view", castMap(config.get("editorConfig")).get("mode")); verify(sessionMapper, never()).update(any(OnlyOfficeSessionEntity.class), any()); } @Test public void publishFenceWonAfterModeResolutionReloadsAsReadOnly() { OnlyOfficeConfigParam param = annotationParam(); param.setMode("edit"); OnlyOfficeSessionEntity idle = session(); idle.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE); OnlyOfficeSessionEntity publishing = session(); publishing.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.PUBLISHING); publishing.setPublishToken("publish-token"); publishing.setDocKey("publishing-key"); when(sessionMapper.selectOne(any())).thenReturn(idle, publishing); when(sessionMapper.update(any(OnlyOfficeSessionEntity.class), any())).thenReturn(0); when(pluginResolver.resolve(param)).thenReturn(plugins(param, "eln-template-annotator")); UserProvider.setLocalLoginUser(user()); Map config = service.buildEditorConfig(param); assertEquals("view", castMap(config.get("editorConfig")).get("mode")); assertEquals("publishing-key", castMap(config.get("document")).get("key")); } @Test public void publishingSessionCannotClaimSave() { OnlyOfficeSessionEntity publishing = session(); publishing.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.PUBLISHING); publishing.setPublishToken("publish-token"); when(sessionMapper.update(isNull(), any())).thenReturn(0); assertEquals(false, invokePrivate("claimSave", new Class[]{OnlyOfficeSessionEntity.class}, publishing)); } @Test public void locateControlTagForcesViewAndIsInjectedOnlyIntoAnnotator() { OnlyOfficeConfigParam param = annotationParam(); param.setLocateControlTag("ELN_FIELD_orphan"); param.setMode("edit"); OnlyOfficeSessionEntity session = session(); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE); when(sessionMapper.selectOne(any())).thenReturn(session); when(pluginResolver.resolve(param)).thenReturn( plugins(param, "eln-template-annotator", "document-helper")); when(annotateTicketIssuer.issue(any(), any(), any())).thenReturn("annotation-ticket"); UserProvider.setLocalLoginUser(user()); Map config = service.buildEditorConfig(param); assertEquals("view", castMap(config.get("editorConfig")).get("mode")); Map annotationContext = pluginContext(config, annotationGuid()); assertEquals("ELN_FIELD_orphan", annotationContext.get("locateControlTag")); assertEquals(false, annotationContext.get("canAnnotate")); assertFalse(annotationContext.containsKey("scope")); assertFalse(annotationContext.containsKey("ticket")); assertFalse(pluginContext(config, "asc.{HELPER}").containsKey("locateControlTag")); assertNull(session.getLockUserId()); verify(annotateTicketIssuer, never()).issue(any(), any(), any()); OnlyOfficeConfigParam fill = recordParam(); fill.setLocateControlTag("ELN_FIELD_orphan"); assertFalse(pluginContext(invokeAssembleConfig( fill, recordSession(), user(), plugins(fill, "eln-record-filler")), fillGuid()) .containsKey("locateControlTag")); } @Test public void locateControlTagAndRepairBindingIdAreMutuallyExclusive() { OnlyOfficeConfigParam param = annotationParam(); param.setLocateControlTag("ELN_FIELD_orphan"); param.setRepairBindingId("binding-1"); UserProvider.setLocalLoginUser(user()); try { service.buildEditorConfig(param); fail("locate and repair must not be active together"); } catch (DataException e) { assertEquals("locateControlTag 与 repairBindingId 不能同时使用", e.getMessage()); } verify(sessionMapper, never()).insert(any(OnlyOfficeSessionEntity.class)); } @Test public void competingEditorCannotReturnEditConfigAfterLockCasFails() { OnlyOfficeConfigParam param = annotationParam(); param.setFileType("annex"); param.setMode("edit"); OnlyOfficeSessionEntity firstSnapshot = session(); firstSnapshot.setId("session-1"); firstSnapshot.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE); OnlyOfficeSessionEntity secondSnapshot = session(); secondSnapshot.setId("session-1"); secondSnapshot.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE); when(sessionMapper.selectOne(any())).thenReturn(firstSnapshot, secondSnapshot); when(sessionMapper.update(isNull(), any())).thenReturn(1); when(sessionMapper.update(any(OnlyOfficeSessionEntity.class), any())).thenReturn(1, 0); when(pluginResolver.resolve(param)).thenReturn(plugins(param, "eln-template-annotator")); when(annotateTicketIssuer.issue(any(), any(), any())).thenReturn("annotation-ticket"); UserProvider.setLocalLoginUser(user("user-1")); Map firstConfig = service.buildEditorConfig(param); assertEquals("edit", castMap(firstConfig.get("editorConfig")).get("mode")); UserProvider.setLocalLoginUser(user("user-2")); try { service.buildEditorConfig(param); fail("expected edit lock conflict"); } catch (DataException e) { assertEquals("文档编辑锁获取失败,请重新打开", e.getMessage()); } assertEquals("user-1", firstSnapshot.getLockUserId()); assertNull(secondSnapshot.getLockUserId()); assertEquals(Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.IDLE), secondSnapshot.getSessionStatus()); verify(sessionMapper, times(2)).update(any(OnlyOfficeSessionEntity.class), argThat(wrapper -> { String sql = wrapper.getSqlSegment(); return sql.contains("f_id") && sql.contains("doc_key") && sql.contains("version_no") && sql.contains("f_version") && sql.contains("f_delete_mark") && sql.contains("session_status") && sql.contains("lock_user_id") && sql.contains("lock_heartbeat") && sql.contains(" OR "); })); verify(sessionMapper, never()).updateById(any(OnlyOfficeSessionEntity.class)); } @Test public void annotationSceneDoesNotLeakContextToOtherPlugin() { OnlyOfficeConfigParam param = annotationParam(); param.setRepairBindingId("binding-1"); List plugins = plugins( param, "eln-template-annotator", "document-helper"); Map helper = pluginContext( invokeAssembleConfig(param, session(), user(), plugins), "asc.{HELPER}"); assertFalse(helper.containsKey("scope")); assertFalse(helper.containsKey("ticket")); assertFalse(helper.containsKey("canAnnotate")); assertFalse(helper.containsKey("repairBindingId")); } @Test public void repairHintIsIgnoredOutsideTargetSceneAndPlugin() { OnlyOfficeConfigParam fillParam = recordParam(); fillParam.setRepairBindingId("binding-1"); Map fillContext = pluginContext(invokeAssembleConfig( fillParam, recordSession(), user(), plugins(fillParam, "eln-record-filler")), fillGuid()); OnlyOfficeConfigParam otherParam = param("other.scene", "document-helper"); otherParam.setRepairBindingId("binding-1"); Map otherContext = pluginContext(invokeAssembleConfig( otherParam, session(), user(), plugins(otherParam, "document-helper")), "asc.{HELPER}"); assertFalse(fillContext.containsKey("repairBindingId")); assertFalse(otherContext.containsKey("repairBindingId")); } @Test public void recordSaveCommitsStructuredValuesBeforeCompletingSession() { OnlyOfficeSessionEntity session = recordSession(); ActionResult prepared = operationResult( RecordFillSaveOperation.PREPARED, "operation-1", null); ActionResult committed = operationResult( RecordFillSaveOperation.COMMITTED, "operation-1", null); when(fileUploadApi.downFile(any())).thenReturn(downloadResponse("original-document")); when(fileUploadApi.replaceFile(any(), eq("WebAnnexFile/"), eq("template.docx"))) .thenReturn(true); when(recordFillApi.preflight(any())).thenReturn(prepared); when(recordFillApi.commit(any())).thenReturn(committed); invokeSaveBack(session, callback(), true); verify(recordFillApi).preflight(argThat(command -> "tenant-a".equals(command.getTenantId()) && "user-1".equals(command.getUserId()) && "record-1".equals(command.getRecordId()) && "lease-a".equals(command.getSessionDocKey()))); verify(recordFillApi).commit(argThat(command -> "operation-1".equals(command.getOperationCode()))); assertEquals(Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.IDLE), session.getSessionStatus()); assertEquals(Integer.valueOf(2), session.getVersionNo()); } @Test public void rejectedRecordSaveRestoresOriginalAndReturnsLeaseToEditing() { OnlyOfficeSessionEntity session = recordSession(); ActionResult prepared = operationResult( RecordFillSaveOperation.PREPARED, "operation-1", null); ActionResult rejected = operationResult( RecordFillSaveOperation.REJECTED, "operation-1", "记录版本已过期"); when(fileUploadApi.downFile(any())).thenReturn(downloadResponse("original-document")); when(fileUploadApi.replaceFile(any(), eq("WebAnnexFile/"), eq("template.docx"))) .thenReturn(true, true); when(recordFillApi.preflight(any())).thenReturn(prepared); when(recordFillApi.commit(any())).thenReturn(rejected); try { invokeSaveBack(session, callback(), true); fail("rejected record save must fail the callback"); } catch (DataException expected) { assertEquals("记录版本已过期", expected.getMessage()); } verify(fileUploadApi, times(2)).replaceFile(any(), eq("WebAnnexFile/"), eq("template.docx")); assertEquals(Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.EDITING), session.getSessionStatus()); assertEquals("lease-a", session.getDocKey()); } @Test public void uncertainRecordSaveKeepsCandidateAndSavingLease() { OnlyOfficeSessionEntity session = recordSession(); ActionResult prepared = operationResult( RecordFillSaveOperation.PREPARED, "operation-1", null); when(fileUploadApi.downFile(any())).thenReturn(downloadResponse("original-document")); when(fileUploadApi.replaceFile(any(), eq("WebAnnexFile/"), eq("template.docx"))) .thenReturn(true); when(recordFillApi.preflight(any())).thenReturn(prepared); when(recordFillApi.commit(any())).thenReturn(prepared); try { invokeSaveBack(session, callback(), true); fail("uncertain record save must require manual reconciliation"); } catch (DataException expected) { assertTrue(expected.getMessage().contains("保存结果不确定")); } verify(fileUploadApi, times(1)).replaceFile(any(), eq("WebAnnexFile/"), eq("template.docx")); assertEquals(Integer.valueOf(SAVING_STATUS), session.getSessionStatus()); assertEquals("lease-a", session.getDocKey()); } @Test public void replacementFailureAbortsPreparedRecordSave() { OnlyOfficeSessionEntity session = recordSession(); ActionResult prepared = operationResult( RecordFillSaveOperation.PREPARED, "operation-1", null); ActionResult aborted = operationResult( RecordFillSaveOperation.REJECTED, "operation-1", "文件写回失败"); when(fileUploadApi.downFile(any())).thenReturn(downloadResponse("original-document")); when(fileUploadApi.replaceFile(any(), eq("WebAnnexFile/"), eq("template.docx"))) .thenReturn(false); when(recordFillApi.preflight(any())).thenReturn(prepared); when(recordFillApi.abort(any())).thenReturn(aborted); try { invokeSaveBack(session, callback(), true); fail("replacement failure must fail the callback"); } catch (DataException expected) { assertEquals("文件服务写回文档失败", expected.getMessage()); } verify(recordFillApi).abort(argThat(command -> "operation-1".equals(command.getOperationCode()))); assertEquals(Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.EDITING), session.getSessionStatus()); } @Test public void onlyValidatedPrintTicketsEnableDmsPrinting() { OnlyOfficeConfigParam param = new OnlyOfficeConfigParam(); param.setFileId("018f0000-0000-7000-8000-000000000702"); param.setFileName("quality-manual.docx"); param.setFileType("dms"); param.setMode("view"); param.setDmsAccessTicket("validated-ticket"); UserProvider.setLocalLoginUser(user()); jnpf.dmsEntity.preview.model.DmsFilePreviewTicket ticket = new jnpf.dmsEntity.preview.model.DmsFilePreviewTicket(); when(dmsTicketValidator.validate(any(), any())).thenReturn(ticket); OnlyOfficeSessionEntity dmsSession = session(); dmsSession.setBizModule(null); dmsSession.setBizDataId(null); dmsSession.setFileId(param.getFileId()); dmsSession.setFileType("dms"); when(sessionMapper.selectOne(any())).thenReturn(dmsSession); for (String scope : List.of(jnpf.dmsEntity.preview.model.DmsFilePreviewTicket.SCOPE, jnpf.dmsEntity.preview.model.DmsFilePreviewTicket.PRINT_SCOPE)) { ticket.setScope(scope); Map config = service.buildEditorConfig(param); Map permissions = castMap(castMap(config.get("document")).get("permissions")); assertEquals(scope.equals(jnpf.dmsEntity.preview.model.DmsFilePreviewTicket.PRINT_SCOPE), permissions.get("print")); assertEquals(false, permissions.get("edit")); assertEquals(false, permissions.get("download")); assertEquals("view", castMap(config.get("editorConfig")).get("mode")); } verify(dmsTicketValidator, times(2)).validate(any(), any()); } @Test public void dmsPreviewIsReadOnlyAndUsesTicketedFileEndpoint() { OnlyOfficeConfigParam param = new OnlyOfficeConfigParam(); param.setFileId("018f0000-0000-7000-8000-000000000702"); param.setFileName("quality-manual.docx"); param.setFileType("dms"); param.setDmsAccessTicket("ticket-value"); UserInfo user = new UserInfo(); user.setUserId("user-1"); user.setUserName("Tester"); Map config = invokeAssembleConfig(param, session(), user); Map document = castMap(config.get("document")); Map permissions = castMap(document.get("permissions")); Map editorConfig = castMap(config.get("editorConfig")); assertEquals(OnlyOfficeUiConfig.previewPermissions(), permissions); assertEquals(OnlyOfficeUiConfig.previewCustomization(), editorConfig.get("customization")); assertEquals("view", editorConfig.get("mode")); assertEquals(false, permissions.get("edit")); assertEquals(false, permissions.get("comment")); assertEquals(false, permissions.get("review")); assertEquals(false, permissions.get("download")); assertEquals(false, permissions.get("print")); assertEquals(false, permissions.get("protect")); assertEquals("http://gateway/api/file/Dms/OnlyOffice/018f0000-0000-7000-8000-000000000702?ticket=ticket-value", document.get("url")); assertNotEquals("key-1", document.get("key")); param.setDmsAccessTicket("next-ticket-value"); Map nextConfig = invokeAssembleConfig(param, session(), user); Map nextDocument = castMap(nextConfig.get("document")); assertNotEquals(document.get("key"), nextDocument.get("key")); } @Test public void dmsPreviewCallbackNeverWritesBackFile() { OnlyOfficeSessionEntity session = session(); session.setFileType("dms"); when(sessionMapper.selectOne(any())).thenReturn(session); OnlyOfficeCallbackParam callback = callback(); callback.setKey(session.getDocKey()); String token = JWTUtil.createToken(Collections.singletonMap("scope", "test"), "test-secret".getBytes(StandardCharsets.UTF_8)); service.handleCallback(callback, "Bearer " + token); verify(fileApi, never()).getPath(any()); verify(fileUploadApi, never()).replaceFile(any(), any(), any()); verify(sessionMapper, never()).updateById(any(OnlyOfficeSessionEntity.class)); } private OnlyOfficeSessionEntity session() { OnlyOfficeSessionEntity session = new OnlyOfficeSessionEntity(); session.setId("session-1"); session.setFileId("template.docx"); session.setFileType("annex"); session.setVersionNo(1); session.setDocKey("key-1"); session.setTenantId("tenant-a"); session.setBizModule("eln.template"); session.setBizDataId("template-1"); return session; } @Test public void uploadEditEnabledAnnotatorAutostartsForDocAndDocxWithPhysicalSource() { for (String extension : List.of("doc", "docx")) { Map config = uploadAnnotatorConfig(extension, "edit", "edit", true, false); Map plugins = castMap(castMap(config.get("editorConfig")).get("plugins")); String guid = "asc.{C764F7D6-055D-4C7D-97FD-83D2C4440585}"; assertEquals(List.of(guid), plugins.get("autostart")); assertEquals(List.of("http://plugins/0.1.0-dev/dms-qianpiye-annotator/config.json"), plugins.get("pluginsData")); Map context = castMap(castMap(plugins.get("options")).get(guid)); assertEquals("physical-file", context.get("documentId")); assertEquals("dms", context.get("bizModule")); assertEquals("dms.qianpiye", context.get("bizScene")); assertEquals("onlyoffice:dms-qianpiye", context.get("scope")); assertEquals("signed-ticket", context.get("ticket")); assertEquals(true, context.get("canAnnotate")); assertFalse(context.containsKey("bizDataId")); assertTrue(String.valueOf(castMap(config.get("document")).get("url")).contains("/annex/physical-file?")); } } @Test public void uploadAnnotatorDoesNotLoadWhenDisabledReviewOrReadonly() { assertNoUploadAnnotator(uploadAnnotatorConfig("docx", "edit", "edit", false, false)); assertNoUploadAnnotator(uploadAnnotatorConfig("docx", "review", "edit", true, false)); assertNoUploadAnnotator(uploadAnnotatorConfig("docx", "edit", "review", true, false)); assertNoUploadAnnotator(uploadAnnotatorConfig("docx", "view", "edit", true, false)); assertNoUploadAnnotator(uploadAnnotatorConfig("docx", "edit", "edit", true, true)); } private void assertNoUploadAnnotator(Map config) { assertFalse(castMap(config.get("editorConfig")).containsKey("plugins")); } private Map uploadAnnotatorConfig(String extension, String requestedMode, String leaseMode, boolean enabled, boolean lockedByAnotherUser) { OnlyOfficeProperties properties = new OnlyOfficeProperties(); properties.setJwtSecret("test-secret"); properties.setFileFetchBaseUrl("http://gateway"); properties.getUploadFile().setEditEnabled(true); OnlyOfficeProperties.PluginDefinition definition = new OnlyOfficeProperties.PluginDefinition(); definition.setGuid("asc.{C764F7D6-055D-4C7D-97FD-83D2C4440585}"); definition.setVersion("0.1.0"); definition.setRelease("0.1.0-dev"); definition.setPublicBaseUrl("http://plugins"); definition.setApiBaseUrl("http://gateway"); properties.getPlugins().setDefinitions(Map.of("dms-qianpiye-annotator", definition)); OnlyOfficeProperties.PluginSceneMapping mapping = new OnlyOfficeProperties.PluginSceneMapping(); mapping.setBizScene("dms.qianpiye"); mapping.setBizModule("dms"); mapping.setPluginCodes(List.of("dms-qianpiye-annotator")); // 上传开关自身要求自动启动,不依赖独立签批页场景的自动启动设置。 properties.getPlugins().setScenes(List.of(mapping)); UploadEditSessionSupport support = mock(UploadEditSessionSupport.class); jnpf.base.model.uploadedit.UploadEditLease lease = new jnpf.base.model.uploadedit.UploadEditLease(); lease.setDraftId("draft"); lease.setMode(leaseMode); lease.setQianpiyeAnnotatorEnabled(enabled); lease.setFile(Map.of("fileId", "physical-file", "name", "source." + extension)); when(support.lease("tenant-a", "user-1", "lease")).thenReturn(lease); DmsQianpiyeTicketIssuer issuer = mock(DmsQianpiyeTicketIssuer.class); when(issuer.issue(any(), any(), any())).thenReturn("signed-ticket"); OnlyOfficeSceneConfigRegistry registry = new OnlyOfficeSceneConfigRegistry( List.of(new UploadEditSceneConfigBuilder(support, properties, issuer)), new DefaultSceneConfigBuilder(new OnlyOfficeEditPermissionEvaluator(properties))); OnlyOfficeServiceImpl uploadService = new OnlyOfficeServiceImpl(properties, sessionMapper, new OnlyOfficePluginResolver(properties), registry, templateEditabilityApi, recordFillApi, dmsTicketValidator, fileApi, fileUploadApi, new DmsQianpiyeDocxPropertiesWriter()); OnlyOfficeSessionEntity session = session(); session.setFileId("lease"); session.setFileName("source." + extension); session.setBizModule("visualdev"); session.setBizScene("visualdev.uploadFile"); session.setBizDataId("draft"); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE); if (lockedByAnotherUser) { session.setLockUserId("other-user"); session.setLockHeartbeat(new Date()); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); } when(sessionMapper.selectOne(any())).thenReturn(session); when(sessionMapper.update(any(OnlyOfficeSessionEntity.class), any())).thenReturn(1); OnlyOfficeConfigParam param = new OnlyOfficeConfigParam(); param.setFileId("lease"); param.setFileName(session.getFileName()); param.setFileType("annex"); param.setBizModule("visualdev"); param.setBizScene("visualdev.uploadFile"); param.setBizDataId("draft"); param.setMode(requestedMode); UserProvider.setLocalLoginUser(user()); Map config = uploadService.buildEditorConfig(param); if (!castMap(config.get("editorConfig")).containsKey("plugins")) verifyNoTicket(issuer); return config; } private void verifyNoTicket(DmsQianpiyeTicketIssuer issuer) { verify(issuer, never()).issue(any(), any(), any()); } private OnlyOfficeSessionEntity recordSession() { OnlyOfficeSessionEntity session = session(); session.setBizModule("eln.record"); session.setBizDataId("record-1"); session.setDocKey("lease-a"); session.setLockUserId("user-1"); session.setLockHeartbeat(new Date()); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.EDITING); session.setVersion(41); return session; } @SuppressWarnings("unchecked") private ActionResult operationResult( String status, String operationCode, String reason) { RecordFillSaveOperation operation = new RecordFillSaveOperation(); operation.setStatus(status); operation.setOperationCode(operationCode); operation.setReason(reason); ActionResult result = mock(ActionResult.class); when(result.getCode()).thenReturn(200); when(result.getData()).thenReturn(operation); return result; } private Response downloadResponse(String body) { byte[] bytes = body.getBytes(StandardCharsets.UTF_8); return Response.builder().status(200).reason("OK") .request(Request.create(Request.HttpMethod.GET, "http://file/download", Collections.>emptyMap(), null, Charset.defaultCharset())) .body(new Response.Body() { @Override public Integer length() { return bytes.length; } @Override public boolean isRepeatable() { return true; } @Override public ByteArrayInputStream asInputStream() { return new ByteArrayInputStream(bytes); } @Override public java.io.Reader asReader(Charset charset) { throw new UnsupportedOperationException(); } @Override public void close() { } }).build(); } private void assertProbeDeniedReturnsView(ActionResult result, RuntimeException failure) { OnlyOfficeConfigParam param = annotationParam(); param.setMode("edit"); OnlyOfficeSessionEntity session = session(); session.setSessionStatus(OnlyOfficeSessionEntity.SessionStatus.IDLE); when(sessionMapper.selectOne(any())).thenReturn(session); when(pluginResolver.resolve(param)).thenReturn(plugins(param, "eln-template-annotator")); when(annotateTicketIssuer.issue(any(), any(), any())).thenReturn("annotation-ticket"); if (failure == null) { when(templateEditabilityApi.canAnnotate(any(), any(), any(), any())).thenReturn(result); } else { when(templateEditabilityApi.canAnnotate(any(), any(), any(), any())).thenThrow(failure); } UserProvider.setLocalLoginUser(user()); Map config = service.buildEditorConfig(param); assertEquals("view", castMap(config.get("editorConfig")).get("mode")); assertNull(session.getLockUserId()); assertEquals(Integer.valueOf(OnlyOfficeSessionEntity.SessionStatus.IDLE), session.getSessionStatus()); assertFalse(castMap(config.get("editorConfig")).containsKey("plugins")); verify(annotateTicketIssuer, never()).issue(any(), any(), any()); } @SuppressWarnings("unchecked") private ActionResult probeResult(int code, boolean data) { ActionResult result = mock(ActionResult.class); when(result.getCode()).thenReturn(code); when(result.getData()).thenReturn(data); return result; } private UserInfo user() { return user("user-1"); } private UserInfo user(String userId) { UserInfo user = new UserInfo(); user.setTenantId("tenant-a"); user.setUserId(userId); user.setUserName("Tester"); return user; } private OnlyOfficeConfigParam annotationParam() { OnlyOfficeConfigParam param = param("eln.template.annotate", "eln-template-annotator"); param.setFileType("annex"); return param; } private OnlyOfficeConfigParam recordParam() { OnlyOfficeConfigParam param = param("eln.record.fill", "eln-record-filler"); param.setBizModule("eln.record"); param.setBizDataId("record-1"); param.setFileType("annex"); return param; } private OnlyOfficeConfigParam param(String scene, String pluginCode) { OnlyOfficeConfigParam param = new OnlyOfficeConfigParam(); param.setFileId("template.docx"); param.setFileName("template.docx"); param.setBizDataId("template-1"); param.setBizModule("eln.template"); param.setBizScene(scene); return param; } private List plugins( OnlyOfficeConfigParam param, String... pluginCodes) { OnlyOfficeProperties properties = new OnlyOfficeProperties(); Map definitions = new LinkedHashMap<>(); for (String pluginCode : pluginCodes) { OnlyOfficeProperties.PluginDefinition definition = new OnlyOfficeProperties.PluginDefinition(); definition.setGuid(guid(pluginCode)); definition.setVersion("0.1.0"); definition.setRelease("0.1.0"); definition.setPublicBaseUrl("https://plugins.example.com"); definition.setApiBaseUrl("https://api.example.com"); definitions.put(pluginCode, definition); } properties.getPlugins().setDefinitions(definitions); OnlyOfficeProperties.PluginSceneMapping mapping = new OnlyOfficeProperties.PluginSceneMapping(); mapping.setBizScene(param.getBizScene()); mapping.setBizModule(param.getBizModule()); mapping.setPluginCodes(java.util.Arrays.asList(pluginCodes)); properties.getPlugins().setScenes(Collections.singletonList(mapping)); return new OnlyOfficePluginResolver(properties).resolve(param); } private String guid(String pluginCode) { if ("eln-template-annotator".equals(pluginCode)) { return annotationGuid(); } if ("eln-record-filler".equals(pluginCode)) { return fillGuid(); } return "asc.{HELPER}"; } private int countOccurrences(String source, String target) { int count = 0; int index = 0; while ((index = source.indexOf(target, index)) >= 0) { count++; index += target.length(); } return count; } private String annotationGuid() { return "asc.{2D6D6CB6-F6FC-45B7-8EC7-0CC1940F1A6F}"; } private String fillGuid() { return "asc.{7F4E8F35-5D66-47F8-A5B4-6AB3FAACF565}"; } private OnlyOfficeCallbackParam callback() { OnlyOfficeCallbackParam callback = new OnlyOfficeCallbackParam(); callback.setStatus(OnlyOfficeCallbackParam.Status.READY_FOR_SAVING); callback.setUrl("http://127.0.0.1:" + server.getAddress().getPort() + "/saved.docx"); return callback; } private String callbackToken() { return "Bearer " + cn.hutool.jwt.JWTUtil.createToken(Collections.emptyMap(), "test-secret".getBytes(StandardCharsets.UTF_8)); } private Object invokePrivate(String methodName, Class[] parameterTypes, Object... arguments) { try { Method method = OnlyOfficeServiceImpl.class.getDeclaredMethod(methodName, parameterTypes); method.setAccessible(true); return method.invoke(service, arguments); } catch (InvocationTargetException e) { if (e.getCause() instanceof RuntimeException) { throw (RuntimeException) e.getCause(); } throw new RuntimeException(e.getCause()); } catch (ReflectiveOperationException e) { throw new RuntimeException(e); } } private void invokeSaveBack(OnlyOfficeSessionEntity session, OnlyOfficeCallbackParam callback, boolean rotateKey) { try { Method method = OnlyOfficeServiceImpl.class.getDeclaredMethod( "saveBack", OnlyOfficeSessionEntity.class, OnlyOfficeCallbackParam.class, boolean.class); method.setAccessible(true); method.invoke(service, session, callback, rotateKey); } catch (InvocationTargetException e) { if (e.getCause() instanceof RuntimeException) { throw (RuntimeException) e.getCause(); } throw new RuntimeException(e.getCause()); } catch (ReflectiveOperationException e) { throw new RuntimeException(e); } } @SuppressWarnings("unchecked") private Map invokeAssembleConfig(OnlyOfficeConfigParam param, OnlyOfficeSessionEntity session, UserInfo user) { return invokeAssembleConfig(param, session, user, Collections.emptyList()); } @SuppressWarnings("unchecked") private Map invokeAssembleConfig(OnlyOfficeConfigParam param, OnlyOfficeSessionEntity session, UserInfo user, List plugins) { return invokeAssembleConfig(param, session, user, "view", plugins); } @SuppressWarnings("unchecked") private Map invokeAssembleConfig(OnlyOfficeConfigParam param, OnlyOfficeSessionEntity session, UserInfo user, String mode, List plugins) { try { Method method = OnlyOfficeServiceImpl.class.getDeclaredMethod("assembleConfig", OnlyOfficeConfigParam.class, OnlyOfficeSessionEntity.class, String.class, String.class, String.class, UserInfo.class, List.class); method.setAccessible(true); return (Map) method.invoke(service, param, session, "word", "docx", mode, user, plugins); } catch (ReflectiveOperationException e) { throw new RuntimeException(e); } } @SuppressWarnings("unchecked") private Map castMap(Object value) { return (Map) value; } private Map pluginContext(Map config, String guid) { Map editorConfig = castMap(config.get("editorConfig")); Map plugins = castMap(editorConfig.get("plugins")); return castMap(castMap(plugins.get("options")).get(guid)); } }