package jnpf.base.service; import jnpf.base.UserInfo; import jnpf.base.entity.VisualdevEntity; import jnpf.base.model.flow.DataModel; import jnpf.base.model.uploadedit.UploadEditDraft; import jnpf.base.util.FlowFormDataUtil; import jnpf.exception.DataException; import jnpf.flowable.WorkFlowApi; import jnpf.flowable.model.filepermission.FilePermissionPolicy.Action; import jnpf.flowable.model.filepermission.FilePermissionView; import jnpf.model.visualjson.FieLdsModel; import jnpf.model.visualjson.TableModel; import jnpf.model.visualjson.analysis.*; import jnpf.util.JsonUtil; import jnpf.util.TicketUtil; import jnpf.util.UserProvider; import org.junit.*; import org.mockito.MockedStatic; import org.springframework.beans.factory.ObjectProvider; import java.util.*; import static org.junit.Assert.*; import static org.mockito.Mockito.*; public class UploadFilePermissionServiceTest { private UploadFilePermissionService service; private DataModel model; private Map baseline; private FilePermissionView permissions; private UploadFilePermissionService.SaveContext context; private MockedStatic users; private FlowFormDataUtil forms; private WorkFlowApi workflow; private VisualdevService models; @Before public void setup() { jnpf.util.context.SpringContext.setApplicationContext(mock(org.springframework.context.ApplicationContext.class)); UserInfo user = new UserInfo(); user.setUserId("owner"); user.setTenantId("tenant"); users = mockStatic(UserProvider.class); users.when(UserProvider::getUser).thenReturn(user); ObjectProvider modelProvider = mock(ObjectProvider.class); ObjectProvider formProvider = mock(ObjectProvider.class); models = mock(VisualdevService.class); forms = mock(FlowFormDataUtil.class); workflow = mock(WorkFlowApi.class); when(modelProvider.getObject()).thenReturn(models); when(formProvider.getObject()).thenReturn(forms); service = new UploadFilePermissionService(modelProvider, formProvider, new UploadFilePermissionQuery(workflow, mock(ObjectProvider.class))); model = new DataModel(); model.setVisualId("model"); model.setMainId("record"); model.setDataNewMap(new HashMap<>()); model.setTableModelList(new ArrayList<>()); FormAllModel field = new FormAllModel(); FormColumnModel column = new FormColumnModel(); column.setFieLdsModel(field()); field.setFormColumnModel(column); model.setFormAllModel(Collections.singletonList(field)); baseline = new HashMap<>(); baseline.put("id", "record"); baseline.put("files", files("A")); baseline.put("flowTaskId", "actual-task"); model.getDataNewMap().put("files", files("A")); permissions = new FilePermissionView(); permissions.setTaskId("actual-task"); permissions.setFormId("model"); permissions.setStatus("ready"); permissions.setRevision("revision"); allow("files"); context = new UploadFilePermissionService.SaveContext(baseline, permissions); } @Test public void persistedMenuDraftWithoutTaskUsesNativeSaveButMissingTaskInRunningRecordFails() { VisualdevEntity released = new VisualdevEntity(); when(models.getReleaseInfo("model")).thenReturn(released); when(forms.getFilePermissionDataInfo(eq(released), eq("record"), any())).thenReturn(baseline); when(workflow.filePermissions("actual-task", "model")).thenReturn(FilePermissionView.unavailable("actual-task", "model", "TASK_NOT_FOUND")); Map state = new HashMap<>(); state.put("flowId", "flow"); state.put("flowState", 0); state.put("deleteMark", null); baseline.put(FilePermissionNativeDraft.STATE_KEY, state); assertNull(service.prepare(model)); state.put("flowState", 1); model.getDataNewMap().put("flowState", 0); model.getDataNewMap().put("isNew", true); denied("权限暂不可用", () -> service.prepare(model)); state.put("flowState", 0); state.put("deleteMark", 1); denied("权限暂不可用", () -> service.prepare(model)); state.put("deleteMark", null); when(workflow.filePermissions("actual-task", "model")).thenReturn(FilePermissionView.unavailable("actual-task", "model", "PERMISSION_UNAVAILABLE")); denied("权限暂不可用", () -> service.prepare(model)); } @After public void cleanup() { users.close(); } private FieLdsModel field() { return JsonUtil.getJsonToBean("{\"__vModel__\":\"files\",\"__config__\":{\"jnpfKey\":\"uploadFile\"}}", FieLdsModel.class); } private List> files(String... ids) { List> result = new ArrayList<>(); for (String id : ids) { Map f = new HashMap<>(); f.put("fileId", id); result.add(f); } return result; } private void allow(String field, Action... actions) { FilePermissionView.FieldAccess access = new FilePermissionView.FieldAccess(); access.setStatus("ready"); Map values = new EnumMap<>(Action.class); for (Action action : actions) values.put(action, true); access.setActions(values); permissions.getFields().put(field, access); } private void validate(UploadEditDraft... drafts) { service.validate(context, model, Arrays.asList(drafts), f -> false); } private void denied(String message, Runnable call) { try { call.run(); fail("expected rejection: " + message); } catch (DataException expected) { assertTrue(expected.getMessage(), expected.getMessage().contains(message)); } } private UploadEditDraft draft(String origin, String result) { UploadEditDraft d = new UploadEditDraft(); d.setId("draft"); d.setOwnerId("owner"); d.setRecordId("record"); d.setModelId("model"); d.setField("files"); d.setBaselineJson(JsonUtil.getObjectToString(files("A"))); d.setCurrentJson(JsonUtil.getObjectToString(files(result))); d.setEditOriginsJson(JsonUtil.getObjectToString(Collections.singletonMap(result, origin))); return d; } private void children() { FormAllModel item = new FormAllModel(); FormColumnTableModel child = new FormColumnTableModel(); child.setTableModel("rows"); child.setTableName("child_table"); FormColumnModel column = new FormColumnModel(); column.setFieLdsModel(field()); child.setChildList(Collections.singletonList(column)); item.setChildList(child); model.setFormAllModel(Collections.singletonList(item)); TableModel table = new TableModel(); table.setTable("child_table"); model.setTableModelList(Collections.singletonList(table)); when(forms.getKey(eq(table), any())).thenReturn("custom_id"); Map row = new HashMap<>(); row.put("custom_id", "row-1"); row.put("files", files("A")); baseline.put("rows", Collections.singletonList(row)); model.getDataNewMap().put("rows", new ArrayList<>(Collections.singletonList(new HashMap<>(row)))); allow("rows-files"); } @Test public void usesRealRecordTaskAndPreservesLegacy() { VisualdevEntity released = new VisualdevEntity(); when(models.getReleaseInfo("model")).thenReturn(released); when(forms.getFilePermissionDataInfo(eq(released), eq("record"), any())).thenReturn(baseline); model.getDataNewMap().put("flowTaskId", "forged-task"); when(workflow.filePermissions("actual-task", "model")).thenReturn(FilePermissionView.legacy("actual-task", "model")); assertNull(service.prepare(model)); verify(workflow).filePermissions("actual-task", "model"); } @Test public void mismatchedPermissionResponseFailsClosed() { VisualdevEntity released = new VisualdevEntity(); when(models.getReleaseInfo("model")).thenReturn(released); when(forms.getFilePermissionDataInfo(eq(released), anyString(), any())).thenReturn(baseline); when(workflow.filePermissions("actual-task", "model")).thenReturn(FilePermissionView.legacy("other-task", "model")); denied("权限暂不可用", () -> service.prepare(model)); } @Test public void savedEmptyBaselineStillRequiresAddPermission() { VisualdevEntity released = new VisualdevEntity(); when(models.getReleaseInfo("model")).thenReturn(released); baseline.put("files", Collections.emptyList()); when(forms.getFilePermissionDataInfo(eq(released), eq("record"), any())).thenReturn(baseline); permissions.setEnabled(true); when(workflow.filePermissions("actual-task", "model")).thenReturn(permissions); model.getDataNewMap().put("flowTaskId", ""); model.getDataNewMap().put("id", ""); model.getDataNewMap().put("isNew", true); context = service.prepare(model); assertNotNull(context); Map uploaded = files("A").get(0); uploaded.put("uploadReceipt", "receipt"); model.getDataNewMap().put("files", Collections.singletonList(uploaded)); try (MockedStatic tickets = mockStatic(TicketUtil.class)) { tickets.when(() -> TicketUtil.parseTicket("receipt")).thenReturn( "{\"purpose\":\"upload-edit-source\",\"tenantId\":\"tenant\",\"ownerId\":\"owner\",\"fileId\":\"A\"}"); denied("ADD", () -> validate()); } } @Test public void missingSavedBaselineCannotFallBackToInitialCreate() { VisualdevEntity released = new VisualdevEntity(); when(models.getReleaseInfo("model")).thenReturn(released); when(forms.getFilePermissionDataInfo(eq(released), eq("record"), any())).thenReturn(Collections.emptyMap()); denied("记录不存在", () -> service.prepare(model)); verifyNoInteractions(workflow); } @Test public void unchangedFilesNeedNoMutationPermissionAndCannotForgeUrl() { Map forged = files("A").get(0); forged.put("url", "other-file"); model.getDataNewMap().put("files", Collections.singletonList(forged)); validate(); assertEquals(files("A"), model.getDataNewMap().get("files")); } @Test public void omittedMainColumnIsRemovalNotPatch() { model.getDataNewMap().remove("files"); denied("REMOVE", () -> validate()); } @Test public void wrongCaseMainColumnCannotHideNativeNullWrite() { model.getDataNewMap().remove("files"); model.getDataNewMap().put("FILES", files("A")); denied("REMOVE", () -> validate()); } @Test public void ignoredNativeColumnDoesNotRequireActions() { model.getDataNewMap().remove("files"); service.validate(context, model, Collections.emptyList(), f -> true); } @Test public void wholeChildRowDeletionRequiresRemoveEvenWhenColumnIsReadOnly() { children(); model.getDataNewMap().put("rows", Collections.emptyList()); denied("REMOVE", () -> service.validate(context, model, Collections.emptyList(), f -> true)); } @Test public void omittedChildTableAlsoRequiresRemove() { children(); model.getDataNewMap().remove("rows"); denied("REMOVE", () -> validate()); } @Test public void onlyUpdatePreservesMissingRows() { children(); model.setOnlyUpdate(true); model.getDataNewMap().remove("rows"); validate(); } @Test public void omittedChildFileColumnRequiresRemove() { children(); Map row = new HashMap<>(); row.put("CUSTOM_ID", "row-1"); model.getDataNewMap().put("rows", Collections.singletonList(row)); denied("REMOVE", () -> validate()); } @Test public void disabledChildTableIsNotMutatedByNativeSave() { children(); model.getFormAllModel().get(0).getChildList().setDisabled(true); model.getDataNewMap().remove("rows"); validate(); } @Test public void trustedEditIsEditInsteadOfReplacement() { allow("files", Action.EDIT, Action.VIEW); model.getDataNewMap().put("files", files("B")); validate(draft("A", "B")); } @Test public void revokedEditCannotBeAdoptedUsingReplacePermission() { allow("files", Action.REPLACE); model.getDataNewMap().put("files", files("B")); denied("EDIT", () -> validate(draft("A", "B"))); } @Test public void uploadedThenEditedFileStillRequiresReplacement() { allow("files", Action.EDIT, Action.VIEW); model.getDataNewMap().put("files", files("D")); denied("REPLACE", () -> validate(draft("C", "D"))); allow("files", Action.EDIT, Action.VIEW, Action.REPLACE); validate(draft("C", "D")); } @Test public void otherRowOrOwnerDraftIsNotProof() { allow("files", Action.EDIT, Action.REPLACE); model.getDataNewMap().put("files", files("B")); UploadEditDraft other = draft("A", "B"); other.setOwnerId("other"); denied("上传来源", () -> validate(other)); } @Test public void nativeOverrideCannotUseOldDraftAsUploadProof() { allow("files", Action.REPLACE); model.getDataNewMap().put("files", files("B")); context.nativeDraftIds.add("draft"); denied("上传来源", () -> validate(draft("A", "B"))); } @Test public void verifiedUploadMayReplaceAndCannotForgeSourceMetadata() { allow("files", Action.REPLACE); Map uploaded = files("C").get(0); uploaded.put("uploadReceipt", "receipt"); uploaded.put("url", "forged"); model.getDataNewMap().put("files", Collections.singletonList(uploaded)); Map proof = new HashMap<>(); proof.put("purpose", "upload-edit-source"); proof.put("tenantId", "tenant"); proof.put("ownerId", "owner"); proof.put("fileId", "C"); proof.put("url", "actual"); try (MockedStatic tickets = mockStatic(TicketUtil.class)) { tickets.when(() -> TicketUtil.parseTicket("receipt")).thenReturn(JsonUtil.getObjectToString(proof)); validate(); } List accepted = (List) model.getDataNewMap().get("files"); assertEquals("actual", ((Map) accepted.get(0)).get("url")); } @Test public void anotherTenantsUploadReceiptCannotBeUsed() { allow("files", Action.REPLACE); Map uploaded = files("C").get(0); uploaded.put("uploadReceipt", "receipt"); model.getDataNewMap().put("files", Collections.singletonList(uploaded)); try (MockedStatic tickets = mockStatic(TicketUtil.class)) { tickets.when(() -> TicketUtil.parseTicket("receipt")).thenReturn("{\"purpose\":\"upload-edit-source\",\"tenantId\":\"other\",\"ownerId\":\"owner\",\"fileId\":\"C\"}"); denied("上传来源", () -> validate()); } } private void currentDraftContext() { VisualdevEntity released = new VisualdevEntity(); when(models.getReleaseInfo("model")).thenReturn(released); when(forms.getFilePermissionDataInfo(eq(released), eq("record"), any())).thenReturn(baseline); when(workflow.filePermissions("actual-task", "model")).thenReturn(permissions); } private Map nativeDraftContext() { currentDraftContext(); when(workflow.filePermissions("actual-task", "model")) .thenReturn(FilePermissionView.unavailable("actual-task", "model", "TASK_NOT_FOUND")); Map state = new HashMap<>(); state.put("flowId", "flow"); state.put("flowState", 0); state.put("deleteMark", null); baseline.put(FilePermissionNativeDraft.STATE_KEY, state); return state; } @Test public void nativeMenuDraftCanDiscardEditedMainAndChildAttachments() { nativeDraftContext(); UploadEditDraft main = draft("A", "B"); assertTrue(service.canDiscardDraft(main)); service.validateDraftDiscard(main); UploadEditDraft child = draft("A", "C"); child.setParentField("rows"); child.setRowId("row-1"); assertTrue(service.canDiscardDraft(child)); service.validateDraftDiscard(child); assertFalse(service.canDiscardDraft(draft("A", "A"))); service.validateDraftChange(main, files("D")); } @Test public void missingTaskDoesNotAllowDiscardOfRunningDeletedOrUnverifiedRecord() { Map state = nativeDraftContext(); UploadEditDraft edited = draft("A", "B"); state.put("flowState", 1); assertFalse(service.canDiscardDraft(edited)); denied("权限暂不可用", () -> service.validateDraftDiscard(edited)); denied("权限暂不可用", () -> service.validateDraftChange(edited, files("D"))); state.put("flowState", 0); state.put("deleteMark", 1); assertFalse(service.canDiscardDraft(edited)); denied("权限暂不可用", () -> service.validateDraftDiscard(edited)); state.put("deleteMark", null); baseline.put("id", "other-record"); assertFalse(service.canDiscardDraft(edited)); baseline.put("id", "record"); baseline.remove(FilePermissionNativeDraft.STATE_KEY); assertFalse(service.canDiscardDraft(edited)); } @Test public void nativeDraftDiscardDoesNotDowngradePermissionErrorsOrMismatchedResponses() { nativeDraftContext(); UploadEditDraft edited = draft("A", "B"); when(workflow.filePermissions("actual-task", "model")) .thenReturn(FilePermissionView.unavailable("actual-task", "model", "PERMISSION_UNAVAILABLE")); assertFalse(service.canDiscardDraft(edited)); denied("权限暂不可用", () -> service.validateDraftDiscard(edited)); when(workflow.filePermissions("actual-task", "model")) .thenReturn(FilePermissionView.unavailable("other-task", "model", "TASK_NOT_FOUND")); assertFalse(service.canDiscardDraft(edited)); when(workflow.filePermissions("actual-task", "model")).thenThrow(new IllegalStateException("offline")); assertFalse(service.canDiscardDraft(edited)); } @Test public void nativeDraftDiscardUsesInstanceRulesOnceTaskExists() { nativeDraftContext(); UploadEditDraft edited = draft("A", "B"); when(workflow.filePermissions("actual-task", "model")).thenReturn(permissions); assertFalse(service.canDiscardDraft(edited)); denied("EDIT", () -> service.validateDraftDiscard(edited)); allow("files", Action.EDIT); assertTrue(service.canDiscardDraft(edited)); service.validateDraftDiscard(edited); } @Test public void discardDecisionUsesActualChangeAndCurrentPermissions() { currentDraftContext(); allow("files", Action.EDIT); UploadEditDraft edited = draft("A", "B"); assertTrue(service.canDiscardDraft(edited)); allow("files", Action.REPLACE); assertFalse(service.canDiscardDraft(edited)); UploadEditDraft replacement = draft("C", "C"); assertTrue(service.canDiscardDraft(replacement)); replacement.setCurrentJson("[]"); assertFalse(service.canDiscardDraft(replacement)); allow("files", Action.ADD); assertTrue(service.canDiscardDraft(replacement)); } @Test public void discardDecisionIsFalseForUnchangedOrUnavailableDraft() { UploadEditDraft unchanged = draft("A", "A"); assertFalse(service.canDiscardDraft(unchanged)); verifyNoInteractions(workflow); currentDraftContext(); when(workflow.filePermissions("actual-task", "model")).thenThrow(new IllegalStateException("offline")); assertFalse(service.canDiscardDraft(draft("A", "B"))); } @Test public void discardDecisionKeepsLegacyButRejectsMissingEditProvenance() { currentDraftContext(); when(workflow.filePermissions("actual-task", "model")).thenReturn(FilePermissionView.legacy("actual-task", "model")); assertTrue(service.canDiscardDraft(draft("A", "B"))); when(workflow.filePermissions("actual-task", "model")).thenReturn(permissions); allow("files", Action.REPLACE); UploadEditDraft old = draft("A", "onlyoffice-edit-temp,tenant,lease,result.docx"); old.setEditOriginsJson(null); assertFalse(service.canDiscardDraft(old)); } @Test public void discardDecisionUsesChildFieldPermissionsIndependently() { currentDraftContext(); allow("files", Action.EDIT); allow("rows-files"); UploadEditDraft child = draft("A", "B"); child.setParentField("rows"); child.setRowId("row-1"); assertFalse(service.canDiscardDraft(child)); allow("rows-files", Action.EDIT); assertTrue(service.canDiscardDraft(child)); } @Test public void directDraftRemovalCannotBypassRemovePermission() { currentDraftContext(); allow("files", Action.EDIT); denied("REMOVE", () -> service.validateDraftChange(draft("A", "B"), Collections.emptyList())); } @Test public void restoringEditedContentRequiresEditWithoutRequiringReplace() { currentDraftContext(); allow("files", Action.EDIT); service.validateDraftDiscard(draft("A", "B")); allow("files", Action.REPLACE); denied("EDIT", () -> service.validateDraftDiscard(draft("A", "B"))); } @Test public void discardOfUploadReplacementRequiresReplace() { currentDraftContext(); allow("files", Action.EDIT); UploadEditDraft d = draft("C", "D"); denied("REPLACE", () -> service.validateDraftDiscard(d)); allow("files", Action.REPLACE); service.validateDraftDiscard(d); } @Test public void discardOfRemovedAttachmentRequiresAdd() { currentDraftContext(); allow("files", Action.EDIT); UploadEditDraft d = draft("A", "B"); d.setCurrentJson("[]"); d.setEditOriginsJson(null); denied("ADD", () -> service.validateDraftDiscard(d)); } @Test public void missingProvenanceForOldEditedDraftCannotMasqueradeAsReplacement() { allow("files", Action.REPLACE); String id = "onlyoffice-edit-temp,tenant,lease,result.docx"; UploadEditDraft d = draft("A", id); d.setEditOriginsJson(null); model.getDataNewMap().put("files", files(id)); denied("缺少编辑来源", () -> validate(d)); } @Test public void anotherRecordDraftCannotBeUsedForEditAdoption() { allow("files", Action.EDIT, Action.REPLACE); UploadEditDraft d = draft("A", "B"); d.setRecordId("other-record"); model.getDataNewMap().put("files", files("B")); denied("上传来源", () -> validate(d)); } @Test public void incompleteReadCannotBeTreatedAsNonWorkflowRecord() { VisualdevEntity released = new VisualdevEntity(); when(models.getReleaseInfo("model")).thenReturn(released); baseline.remove("flowTaskId"); when(forms.getFilePermissionDataInfo(eq(released), anyString(), any())).thenReturn(baseline); denied("记录不存在", () -> service.prepare(model)); verifyNoInteractions(workflow); } @Test public void conflictingCaseChildKeysCannotHideRemoval() { children(); Map row = new LinkedHashMap<>(); row.put("custom_id", "row-1"); row.put("files", files("A")); row.put("FILES", Collections.emptyList()); model.getDataNewMap().put("rows", Collections.singletonList(row)); denied("大小写冲突", () -> validate()); } @Test public void permittedNullRetainsNativeNullRepresentation() { allow("files", Action.REMOVE); model.getDataNewMap().put("files", null); validate(); assertNull(model.getDataNewMap().get("files")); } @Test public void revokedViewAlsoPreventsAdoptionOfEditedContent() { allow("files", Action.EDIT); model.getDataNewMap().put("files", files("B")); denied("VIEW", () -> validate(draft("A", "B"))); } @Test public void templateSourceReadsNativeBaselineWithoutOnlineEditorAndRejectsRecordMismatch() { VisualdevEntity released = new VisualdevEntity(); when(models.getReleaseInfo("model")).thenReturn(released); when(forms.getFilePermissionDataInfo(eq(released), eq("record"), any())).thenReturn(baseline); baseline.put("wenjian", "[{\"fileId\":\"A\",\"laiyuan_muban_id\":\"templateA\"}]"); assertEquals("templateA", service.dmsMainAttachmentBaseline(model).get(0).get("laiyuan_muban_id")); verifyNoInteractions(workflow); baseline.put("id", "other"); denied("记录不匹配", () -> service.dmsMainAttachmentBaseline(model)); } @Test public void sameFileTemplateRestoreStillRequiresReplaceAndUsesBatchPermissions() { permissions.setEnabled(true); allow("wenjian", Action.VIEW, Action.EDIT); UploadEditDraft d = draft("A", "A"); d.setField("wenjian"); d.setEditOriginsJson("{}"); d.setBaselineJson("[{\"fileId\":\"A\",\"laiyuan_muban_id\":\"templateA\"}]"); d.setCurrentJson("[{\"fileId\":\"A\",\"laiyuan_muban_id\":\"templateB\"}]"); assertFalse(service.canDiscardDraft(d, permissions)); allow("wenjian", Action.VIEW, Action.EDIT, Action.REPLACE); assertTrue(service.canDiscardDraft(d, permissions)); verifyNoInteractions(models, forms, workflow); } }