刘光辉
7 小时以前 06f314545ac3514d7ce28939920866ae8188d31c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
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<String, Object> baseline;
    private FilePermissionView permissions;
    private UploadFilePermissionService.SaveContext context;
    private MockedStatic<UserProvider> 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<VisualdevService> modelProvider = mock(ObjectProvider.class);
        ObjectProvider<FlowFormDataUtil> 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<String,Object> 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<Map<String, Object>> files(String... ids) {
        List<Map<String, Object>> result = new ArrayList<>();
        for (String id : ids) { Map<String, Object> 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<Action, Boolean> 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<String, Object> 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<String, Object> uploaded = files("A").get(0); uploaded.put("uploadReceipt", "receipt");
        model.getDataNewMap().put("files", Collections.singletonList(uploaded));
        try (MockedStatic<TicketUtil> 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<String, Object> 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<String, Object> 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<String, Object> uploaded = files("C").get(0); uploaded.put("uploadReceipt", "receipt"); uploaded.put("url", "forged");
        model.getDataNewMap().put("files", Collections.singletonList(uploaded));
        Map<String, Object> 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<TicketUtil> 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<String, Object> uploaded = files("C").get(0); uploaded.put("uploadReceipt", "receipt");
        model.getDataNewMap().put("files", Collections.singletonList(uploaded));
        try (MockedStatic<TicketUtil> 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<String, Object> nativeDraftContext() {
        currentDraftContext();
        when(workflow.filePermissions("actual-task", "model"))
                .thenReturn(FilePermissionView.unavailable("actual-task", "model", "TASK_NOT_FOUND"));
        Map<String, Object> 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<String, Object> 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<String, Object> 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);
    }
}