package com.dms.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import com.dms.common.DmsException; import com.dms.mapper.AttachmentBindingMapper; import com.dms.mapper.CategoryMapper; import com.dms.mapper.DocumentMapper; import com.dms.mapper.OrganizationMapper; import com.dms.mapper.PermissionMapper; import com.dms.mapper.RoleMapper; import com.dms.mapper.RolePermissionMapper; import com.dms.mapper.UserMapper; import com.dms.model.DocumentEntity; import com.dms.model.OrganizationEntity; import com.dms.model.PermissionEntity; import com.dms.model.RoleEntity; import com.dms.model.RolePermissionEntity; import com.dms.security.AuthContext; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.List; import org.junit.jupiter.api.Test; /** * 契约1.36「操作位完全拆分」专项回归(对齐 Python 端 * test_role_authorization 的 1.36 用例,Python 版废弃后由本类守护): * 查看、下载、编辑、删除等操作位各自独立判定,查看不再是其他位的前置; * 密级闸仍优先于一切操作位不可绕过。 */ class AuthorizationServicePlanSplitTest { private final DocumentMapper documentMapper = mock(DocumentMapper.class); private final PermissionMapper permissionMapper = mock(PermissionMapper.class); private final AttachmentBindingMapper attachmentBindingMapper = mock(AttachmentBindingMapper.class); private final OrganizationMapper organizationMapper = mock(OrganizationMapper.class); private final UserMapper userMapper = mock(UserMapper.class); private final RoleMapper roleMapper = mock(RoleMapper.class); private final RolePermissionMapper rolePermissionMapper = mock(RolePermissionMapper.class); private final CategoryMapper categoryMapper = mock(CategoryMapper.class); private final AuthorizationService service = new AuthorizationService( documentMapper, categoryMapper, permissionMapper, attachmentBindingMapper, organizationMapper, userMapper, roleMapper, rolePermissionMapper); // ------------------------------------------------------------ 固定场景 /** 已发布 MAIN 主案,密级 SECRET,归属机构 10。 */ private DocumentEntity mainDoc() { DocumentEntity doc = new DocumentEntity(); doc.setId(1L); doc.setDocumentType("MAIN"); doc.setDocumentStatus("PUBLISHED"); doc.setSecurityLevel("SECRET"); doc.setOrganizationId(10L); doc.setCategoryId(21L); return doc; } /** USER 角色用户(id=9),机构 10,密级 SECRET(满足文档密级),会话角色100。 */ private AuthContext userContext() { return new AuthContext(9L, "editor", "编辑用户", 10L, "本部门", "USER", "SECRET", 1, null, 100L); } /** 矩阵行(角色100 × SECRET × OWN),按入参开位。 */ private RolePermissionEntity matrix(Boolean canView, Boolean canDownload, Boolean canEdit, Boolean canDelete) { RolePermissionEntity row = new RolePermissionEntity(); row.setRoleId(100L); row.setSecurityLevel("SECRET"); row.setDeptScope("OWN"); row.setCanView(canView); row.setCanDownload(canDownload); row.setCanEdit(canEdit); row.setCanDelete(canDelete); return row; } /** 生效角色100(会话角色直达,不查库)、用户与文档同机构(OWN)、 * 文档一级分类 21(契约1.48),矩阵行按入参生效。 */ private void stubUserRoleOrgAndMatrix(RolePermissionEntity row) { RoleEntity role = new RoleEntity(); role.setId(100L); when(roleMapper.findActiveEnabledById(100L)).thenReturn(role); OrganizationEntity org = new OrganizationEntity(); org.setId(10L); org.setOrgPath("/root/dept-a/"); when(organizationMapper.findByIdNotDeleted(10L)).thenReturn(org); com.dms.model.CategoryEntity top = new com.dms.model.CategoryEntity(); top.setId(21L); top.setParentId(null); when(categoryMapper.findByIdNotDeleted(21L)).thenReturn(top); when(rolePermissionMapper.findMatrixRow(100L, 21L, "MAIN", "SECRET", "OWN")) .thenReturn(row); } /** 借阅 ACL 行:本用户、限时生效、按入参开位。 */ private PermissionEntity acl(Boolean canView, Boolean canDownload) { PermissionEntity permission = new PermissionEntity(); permission.setDocumentId(1L); permission.setSubjectId(9L); permission.setCanView(canView); permission.setCanDownload(canDownload); permission.setExpiresAt(LocalDateTime.now(ZoneOffset.UTC).plusDays(1)); return permission; } // ------------------------------------------------------------ 用例 @Test void 矩阵仅勾编辑位_查看与下载均不可_编辑写路径放行其余拒绝() { stubUserRoleOrgAndMatrix(matrix(false, false, true, false)); when(permissionMapper.listActiveByDocument(1L)).thenReturn(List.of()); DocumentEntity doc = mainDoc(); AuthContext context = userContext(); AuthorizationService.PlanAccess access = service.evaluatePlanAccess(doc, context); assertThat(access.allowed()).isFalse(); assertThat(access.reason()).isEqualTo("ACL"); assertThat(access.canDownload()).isFalse(); assertThat(access.matrixActions()).containsExactly("EDIT"); assertThat(service.planAllowedActions(doc, context, access)) .containsExactly("EDIT"); // 编辑位独立生效(不要求查看前置) assertThatCode(() -> service.requirePlanAction(doc, context, "EDIT")) .doesNotThrowAnyException(); // 未授予的查看/下载/删除/授权逐位拒绝 assertThatThrownBy(() -> service.requirePlanAction(doc, context, "VIEW")) .isInstanceOf(DmsException.class) .hasMessageContaining("无权查看该文档"); assertThatThrownBy(() -> service.requirePlanAction(doc, context, "DOWNLOAD")) .isInstanceOf(DmsException.class) .hasMessageContaining("无权下载该文档"); assertThatThrownBy(() -> service.requirePlanAction(doc, context, "DELETE")) .isInstanceOf(DmsException.class) .hasMessageContaining("无权删除该文档"); assertThatThrownBy(() -> service.requirePlanAction(doc, context, "CONFIG_PERMISSION")) .isInstanceOf(DmsException.class) .hasMessageContaining("无权授权该文档"); } @Test void 矩阵仅勾下载位_不可查看但可下载() { stubUserRoleOrgAndMatrix(matrix(false, true, false, false)); when(permissionMapper.listActiveByDocument(1L)).thenReturn(List.of()); DocumentEntity doc = mainDoc(); AuthContext context = userContext(); AuthorizationService.PlanAccess access = service.evaluatePlanAccess(doc, context); assertThat(access.allowed()).isFalse(); assertThat(access.reason()).isEqualTo("ACL"); assertThat(access.canDownload()).isTrue(); assertThat(service.planAllowedActions(doc, context, access)) .containsExactly("DOWNLOAD"); assertThatCode(() -> service.requirePlanAction(doc, context, "DOWNLOAD")) .doesNotThrowAnyException(); assertThatThrownBy(() -> service.requirePlanAction(doc, context, "VIEW")) .isInstanceOf(DmsException.class) .hasMessageContaining("无权查看该文档"); } @Test void 借阅ACL仅勾下载_无矩阵命中_下载独立生效() { stubUserRoleOrgAndMatrix(null); when(permissionMapper.listActiveByDocument(1L)) .thenReturn(List.of(acl(false, true))); DocumentEntity doc = mainDoc(); AuthContext context = userContext(); AuthorizationService.PlanAccess access = service.evaluatePlanAccess(doc, context); assertThat(access.allowed()).isFalse(); assertThat(access.canDownload()).isTrue(); assertThat(access.permissions()).hasSize(1); assertThat(service.planAllowedActions(doc, context, access)) .containsExactly("DOWNLOAD"); assertThatCode(() -> service.requirePlanAction(doc, context, "DOWNLOAD")) .doesNotThrowAnyException(); assertThatThrownBy(() -> service.requirePlanAction(doc, context, "VIEW")) .isInstanceOf(DmsException.class) .hasMessageContaining("无权查看该文档"); } @Test void 密级不足时操作位拆分不放松密级闸_任何操作一律拒绝() { // 用户密级 INTERNAL(20) 低于文档 SECRET(30),矩阵即便勾编辑也不放行 stubUserRoleOrgAndMatrix(matrix(false, false, true, false)); DocumentEntity doc = mainDoc(); AuthContext context = new AuthContext(9L, "editor", "低密级用户", 10L, "本部门", "USER", "INTERNAL", 1, null, 100L); assertThatThrownBy(() -> service.requirePlanAction(doc, context, "EDIT")) .isInstanceOf(DmsException.class) .hasMessageContaining("用户密级不足"); assertThatThrownBy(() -> service.requirePlanAction(doc, context, "DOWNLOAD")) .isInstanceOf(DmsException.class) .hasMessageContaining("用户密级不足"); AuthorizationService.PlanAccess access = service.evaluatePlanAccess(doc, context); assertThat(access.allowed()).isFalse(); assertThat(access.reason()).isEqualTo("SECURITY"); assertThat(access.canDownload()).isFalse(); } }