package com.dms.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.dms.common.DeadlockRetry; import com.dms.common.DmsException; import com.dms.common.Errors; import com.dms.config.DmsProperties; import com.dms.mapper.AttachmentBindingMapper; import com.dms.mapper.CategoryMapper; import com.dms.mapper.DocumentMapper; import com.dms.mapper.DocumentRelationMapper; import com.dms.mapper.SubPlanBindingMapper; import com.dms.model.DocumentEntity; import com.dms.security.AuthContext; import com.dms.service.AuthorizationService.AttachmentAccess; import com.dms.service.AuthorizationService.PlanAccess; import com.fasterxml.jackson.databind.ObjectMapper; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.List; import java.util.Set; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.transaction.support.TransactionTemplate; /** * 操作位隔离补全(编辑视图):无查看权但具备任一操作位的用户,详情、 * 附件详情与关联列表放行元数据视图——正文不下发、查看计数与查看审计 * 不记录;无任何操作位与密级不足维持拒绝。写路径判定不受影响。 */ class EditAccessIsolationTest { private final DocumentMapper documentMapper = mock(DocumentMapper.class); private final AttachmentBindingMapper attachmentBindingMapper = mock(AttachmentBindingMapper.class); private final CategoryMapper categoryMapper = mock(CategoryMapper.class); private final SubPlanBindingMapper subPlanBindingMapper = mock(SubPlanBindingMapper.class); private final AuthorizationService authorizationService = mock(AuthorizationService.class); private final AuditService auditService = mock(AuditService.class); private final TransactionTemplate transactionTemplate = mock(TransactionTemplate.class); private final DocumentQueryService queryService = new DocumentQueryService( documentMapper, attachmentBindingMapper, categoryMapper, subPlanBindingMapper, authorizationService, auditService, new ObjectMapper(), transactionTemplate); @BeforeEach void setUp() { AuthContext.set(new AuthContext(9L, "editor", "编辑用户", 10L, "本部门", "USER", "SECRET", 1, null, 100L)); } @AfterEach void clearContext() { AuthContext.clear(); } /** 已发布 MAIN 主案,无方案密码,查看计数 7、rowVersion 3、含正文。 */ private DocumentEntity mainDoc() { DocumentEntity doc = new DocumentEntity(); doc.setId(1L); doc.setDocumentType("MAIN"); doc.setDocumentStatus("PUBLISHED"); doc.setSecurityLevel("SECRET"); doc.setDocumentName("年度演练方案"); doc.setOrganizationId(10L); doc.setCategoryId(21L); doc.setRowVersion(3); doc.setViewCount(7L); doc.setContentText("机密正文内容"); java.time.LocalDateTime now = java.time.LocalDateTime.now(java.time.ZoneOffset.UTC); doc.setCreatedAt(now); doc.setUpdatedAt(now); return doc; } /** 无查看权、矩阵命中 EDIT 的访问结论(reason=ACL 借贷语义)。 */ private PlanAccess editOnlyAccess(DocumentEntity doc) { return new PlanAccess(false, "ACL", doc, List.of(), false, Set.of("EDIT")); } // ------------------------------------------------------------ 文档详情 @Test void documentDetailServesMetadataViewForEditOnlyUser() { DocumentEntity doc = mainDoc(); PlanAccess access = editOnlyAccess(doc); when(documentMapper.findActivePlanById(1L)).thenReturn(doc); when(authorizationService.evaluatePlanAccess(doc, AuthContext.get())).thenReturn(access); when(authorizationService.planAllowedActions(doc, AuthContext.get(), access)) .thenReturn(List.of("EDIT")); when(authorizationService.detailAccessAllowed(doc, AuthContext.get(), access)) .thenReturn(true); var result = queryService.getDocument(1L, new MockHttpServletRequest("GET", "/api/v1/documents/1")); assertThat(result.get("rowVersion")).isEqualTo(3); // 编辑视图:正文属查看类内容不下发;查看计数仅回显 assertThat(result.get("contentText")).isNull(); assertThat(result.get("viewCount")).isEqualTo(7L); verify(documentMapper, never()).incrementViewCount(1L); verify(auditService, never()).insert(any()); } @Test void documentDetailStillForbiddenWithoutAnyAction() { DocumentEntity doc = mainDoc(); PlanAccess access = new PlanAccess(false, "ACL", doc, List.of(), false, Set.of()); when(documentMapper.findActivePlanById(1L)).thenReturn(doc); when(authorizationService.evaluatePlanAccess(doc, AuthContext.get())).thenReturn(access); when(authorizationService.planAllowedActions(doc, AuthContext.get(), access)) .thenReturn(List.of()); assertThatThrownBy(() -> queryService.getDocument(1L, new MockHttpServletRequest("GET", "/api/v1/documents/1"))) .isInstanceOf(DmsException.class) .hasMessage("无权查看该文档"); } @Test void documentDetailStillForbiddenForSecurityLevel() { DocumentEntity doc = mainDoc(); PlanAccess access = new PlanAccess(false, "SECURITY", doc, List.of(), false, Set.of()); when(documentMapper.findActivePlanById(1L)).thenReturn(doc); when(authorizationService.evaluatePlanAccess(doc, AuthContext.get())).thenReturn(access); assertThatThrownBy(() -> queryService.getDocument(1L, new MockHttpServletRequest("GET", "/api/v1/documents/1"))) .isInstanceOf(DmsException.class) .hasMessage(Errors.securityLevelForbidden().getMessage()); } // ------------------------------------------------------------ 关联列表 @Test void relationsReadableInEditView(@TempDir Path tempDir) throws Exception { DmsProperties properties = mock(DmsProperties.class); when(properties.documentRelationPresetConfigPath()) .thenReturn(tempDir.resolve("relation-presets.json")); java.nio.file.Files.writeString(tempDir.resolve("relation-presets.json"), "{\"presets\":[{\"code\":\"BASIS\",\"label\":\"依据\",\"sortNo\":10}]}", StandardCharsets.UTF_8); DocumentRelationMapper relationMapper = mock(DocumentRelationMapper.class); DocumentRelationService relationService = new DocumentRelationService( documentMapper, relationMapper, queryService, authorizationService, auditService, mock(DeadlockRetry.class), properties); DocumentEntity doc = mainDoc(); PlanAccess access = editOnlyAccess(doc); when(documentMapper.findActivePlanById(1L)).thenReturn(doc); when(authorizationService.evaluatePlanAccess(doc, AuthContext.get())).thenReturn(access); when(authorizationService.planAllowedActions(doc, AuthContext.get(), access)) .thenReturn(List.of("EDIT")); when(authorizationService.detailAccessAllowed(doc, AuthContext.get(), access)) .thenReturn(true); when(relationMapper.listRelationItems(1L)).thenReturn(List.of()); var result = relationService.listDocumentRelations(1L); assertThat(result.get("items")).isEqualTo(List.of()); } // ------------------------------------------------------------ 附件详情 private DocumentEntity attachmentDoc() { DocumentEntity attachment = new DocumentEntity(); attachment.setId(5L); attachment.setDocumentType("ATTACHMENT"); attachment.setDocumentStatus("PUBLISHED"); attachment.setSecurityLevel("SECRET"); attachment.setDocumentName("配套方案.pdf"); attachment.setRowVersion(2); attachment.setViewCount(4L); java.time.LocalDateTime now = java.time.LocalDateTime.now(java.time.ZoneOffset.UTC); attachment.setCreatedAt(now); attachment.setUpdatedAt(now); return attachment; } @Test void attachmentDetailServesMetadataViewForEditOnlyUser() { DocumentQueryService documentQueryService = mock(DocumentQueryService.class); AttachmentQueryService attachmentService = new AttachmentQueryService( attachmentBindingMapper, documentMapper, authorizationService, documentQueryService); DocumentEntity attachment = attachmentDoc(); when(documentMapper.findActiveAttachmentById(5L)).thenReturn(attachment); when(authorizationService.requireAttachmentAccess(attachment, AuthContext.get(), false)) .thenThrow(Errors.documentViewForbidden()); when(authorizationService.attachmentAllowedActions( eq(AuthContext.get()), eq(attachment), any())) .thenReturn(List.of("EDIT")); when(authorizationService.evaluateAttachmentAccess(attachment, AuthContext.get())) .thenReturn(new AttachmentAccess(false, false)); when(authorizationService.attachmentEditAllowed(attachment, AuthContext.get())) .thenReturn(true); var result = attachmentService.getAttachment(5L, new MockHttpServletRequest("GET", "/api/v1/attachments/5")); // 编辑视图:VIEW 位不下发、查看计数仅回显不增计 assertThat(result.get("allowedActions")).isEqualTo(List.of("EDIT")); assertThat(result.get("viewCount")).isEqualTo(4L); verify(documentQueryService, never()).recordView(any(), any()); } @Test void attachmentDetailStillForbiddenWithoutAnyAction() { DocumentQueryService documentQueryService = mock(DocumentQueryService.class); AttachmentQueryService attachmentService = new AttachmentQueryService( attachmentBindingMapper, documentMapper, authorizationService, documentQueryService); DocumentEntity attachment = attachmentDoc(); when(documentMapper.findActiveAttachmentById(5L)).thenReturn(attachment); when(authorizationService.requireAttachmentAccess(attachment, AuthContext.get(), false)) .thenThrow(Errors.documentViewForbidden()); when(authorizationService.attachmentAllowedActions( eq(AuthContext.get()), eq(attachment), any())) .thenReturn(List.of()); assertThatThrownBy(() -> attachmentService.getAttachment(5L, new MockHttpServletRequest("GET", "/api/v1/attachments/5"))) .isInstanceOf(DmsException.class) .hasMessage("无权查看该文档"); } }