🐛 小程序用户仅改人口字段时不再重置数据权限范围与自定义组织

This commit is contained in:
2026-10-25 19:38:25 +08:00
parent 6565e7eeea
commit dfe2d3265f
3 changed files with 339 additions and 3 deletions
@@ -0,0 +1,312 @@
package org.springblade.system.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.system.cache.MpUserCache;
import org.springblade.system.mapper.MpUserDataScopeMapper;
import org.springblade.system.mapper.MpUserMapper;
import org.springblade.system.pojo.entity.MpUser;
import org.springblade.system.pojo.entity.MpUserDataScope;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.List;
import java.util.function.Consumer;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 小程序用户身份与数据权限测试。
* <p>
* 注入方式:被测类直接 new(唯一构造参数是 MpUserDataScopeMapper),
* {@code BaseServiceImpl.baseMapper} 用反射写入 MpUserMapper 的 mock,
* 与 blade-transport 的 DriverServiceImplTest 同一套路,不起 Spring 容器。
* {@code MpUserCache.clear()} 内部走 SpringUtil.getBean(CacheManager),无容器即抛空指针,
* 故每次调用前把该静态方法 mock 掉。
* <p>
* 非法入参用例反射调用私有的 validate:updateUser 会先把手机号用库值回填、再补数据权限默认值,
* 空值走不到校验分支。
*/
class MpUserServiceImplTest {
private static final Long USER_ID = 7001L;
private static final Long SCOPE_DEPT_A = 1001L;
private static final Long SCOPE_DEPT_B = 1002L;
private static final String TENANT_ID = "000000";
private static final String PHONE = "15975348524";
private static final String DEPT_ID = "1123598817739906";
private MpUserMapper mpUserMapper;
private MpUserDataScopeMapper mpUserDataScopeMapper;
private MpUserServiceImpl service;
@BeforeAll
static void initTableInfo() {
// 构造 Lambda 条件需要 MyBatis-Plus 的实体元数据缓存
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, MpUser.class);
TableInfoHelper.initTableInfo(assistant, MpUserDataScope.class);
}
@BeforeEach
void setUp() throws Exception {
mpUserMapper = mock(MpUserMapper.class);
mpUserDataScopeMapper = mock(MpUserDataScopeMapper.class);
service = new MpUserServiceImpl(mpUserDataScopeMapper);
inject("baseMapper", mpUserMapper);
}
// ------------------------------------------------------------ 账号组织不再必填
@ParameterizedTest(name = "{2}")
@MethodSource("identityCases")
@DisplayName("编辑小程序用户:司机 / 管理员 / 司机+管理员 × 有组织 / 无组织,六种组合全部放行")
void updateUserAcceptsEmptyDeptForEveryIdentity(String identities, String deptId, String label) {
when(mpUserMapper.selectById(USER_ID)).thenReturn(dbUser());
when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1);
MpUser user = new MpUser();
user.setId(USER_ID);
user.setTenantId(TENANT_ID);
user.setPhone(PHONE);
user.setIdentities(identities);
user.setDeptId(deptId);
assertTrue(updateUser(user), label + ":账号组织不是准入门槛");
}
static Stream<Arguments> identityCases() {
return Stream.of(
Arguments.of("driver", DEPT_ID, "司机 + 有组织"),
Arguments.of("driver", null, "司机 + 无组织"),
Arguments.of("admin", DEPT_ID, "管理员 + 有组织"),
Arguments.of("admin", null, "管理员 + 无组织"),
Arguments.of("driver,admin", DEPT_ID, "司机+管理员 + 有组织"),
Arguments.of("driver,admin", null, "司机+管理员 + 无组织"));
}
@Test
@DisplayName("validate 仍拦截:租户空、手机号空、身份空、数据权限范围与层级非法")
void validateStillRejectsMissingOrIllegalFields() {
assertValidateRejects(user -> user.setTenantId(null), "租户信息不能为空");
assertValidateRejects(user -> user.setPhone(null), "手机号不能为空");
assertValidateRejects(user -> user.setIdentities(null), "小程序身份不能为空");
assertValidateRejects(user -> user.setDataScopeRange(0), "数据权限范围不正确");
assertValidateRejects(user -> user.setDataScopeRange(4), "数据权限范围不正确");
assertValidateRejects(user -> user.setDataLevelRange(0), "数据层级范围不正确");
assertValidateRejects(user -> user.setDataLevelRange(3), "数据层级范围不正确");
}
// ------------------------------------------------------------ 只改人口字段不重置数据权限
@Test
@DisplayName("只改姓名:数据权限范围、层级与自定义组织全部不变,关联表一行都不动")
void updatePersonFieldsOnlyKeepsDataScope() {
when(mpUserMapper.selectById(USER_ID)).thenReturn(customScopeDbUser());
when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1);
when(mpUserDataScopeMapper.selectList(any())).thenReturn(scopes(SCOPE_DEPT_A, SCOPE_DEPT_B));
MpUser user = new MpUser();
user.setId(USER_ID);
user.setTenantId(TENANT_ID);
user.setName("张三");
user.setIdentities("admin");
assertTrue(updateUser(user));
ArgumentCaptor<MpUser> captor = ArgumentCaptor.forClass(MpUser.class);
verify(mpUserMapper).updateById(captor.capture());
assertEquals(3, captor.getValue().getDataScopeRange(), "未提供范围时沿用库里的 3-自定义");
assertEquals(2, captor.getValue().getDataLevelRange(), "未提供层级时沿用库里的 2-仅本级");
assertEquals(List.of(SCOPE_DEPT_A, SCOPE_DEPT_B), captor.getValue().getDataScopeDeptIds());
assertNull(captor.getValue().getDeptId(), "调用方不传组织时 updateById 忽略该列,不得写成空串");
verify(mpUserDataScopeMapper, never()).delete(any());
verify(mpUserDataScopeMapper, never()).insert(any(MpUserDataScope.class));
}
@Test
@DisplayName("显式传 range=2:范围改写为全部组织,自定义组织关联被清空")
void updateWithExplicitRangeRewritesScopes() {
when(mpUserMapper.selectById(USER_ID)).thenReturn(customScopeDbUser());
when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1);
MpUser user = new MpUser();
user.setId(USER_ID);
user.setTenantId(TENANT_ID);
user.setIdentities("admin");
user.setDataScopeRange(2);
assertTrue(updateUser(user));
ArgumentCaptor<MpUser> captor = ArgumentCaptor.forClass(MpUser.class);
verify(mpUserMapper).updateById(captor.capture());
assertEquals(2, captor.getValue().getDataScopeRange());
assertEquals(2, captor.getValue().getDataLevelRange(), "未提供层级时沿用库值");
assertTrue(captor.getValue().getDataScopeDeptIds().isEmpty(), "range != 3 时关联列表被清空");
verify(mpUserDataScopeMapper).delete(any());
verify(mpUserDataScopeMapper, never()).insert(any(MpUserDataScope.class));
}
@Test
@DisplayName("只显式传自定义组织列表:关联按新列表重建,范围沿用库里的 3-自定义")
void updateWithExplicitDeptIdsRebuildsScopes() {
when(mpUserMapper.selectById(USER_ID)).thenReturn(customScopeDbUser());
when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1);
MpUser user = new MpUser();
user.setId(USER_ID);
user.setTenantId(TENANT_ID);
user.setIdentities("admin");
user.setDataScopeDeptIds(List.of(SCOPE_DEPT_A));
assertTrue(updateUser(user));
ArgumentCaptor<MpUser> captor = ArgumentCaptor.forClass(MpUser.class);
verify(mpUserMapper).updateById(captor.capture());
assertEquals(3, captor.getValue().getDataScopeRange(), "未提供范围时沿用库值");
assertEquals(List.of(SCOPE_DEPT_A), captor.getValue().getDataScopeDeptIds());
verify(mpUserDataScopeMapper).delete(any());
verify(mpUserDataScopeMapper).insert(any(MpUserDataScope.class));
}
@Test
@DisplayName("新建小程序用户:仍按提交的 range=3 重建自定义组织关联")
void submitStillRebuildsScopes() {
when(mpUserMapper.selectOne(any(), anyBoolean())).thenReturn(null);
when(mpUserMapper.insert(any(MpUser.class))).thenAnswer(invocation -> {
invocation.getArgument(0, MpUser.class).setId(USER_ID);
return 1;
});
MpUser user = new MpUser();
user.setTenantId(TENANT_ID);
user.setPhone(PHONE);
user.setIdentities("admin");
user.setDataScopeRange(3);
user.setDataScopeDeptIds(List.of(SCOPE_DEPT_A, SCOPE_DEPT_B));
try (MockedStatic<MpUserCache> ignored = mockStatic(MpUserCache.class)) {
assertTrue(service.submit(user));
}
verify(mpUserDataScopeMapper).delete(any());
verify(mpUserDataScopeMapper, times(2)).insert(any(MpUserDataScope.class));
}
// ------------------------------------------------------------ 夹具
/**
* updateUser 成功后会清缓存;缓存实现依赖 Spring 容器,无容器时静态 mock 掉
*/
private boolean updateUser(MpUser user) {
try (MockedStatic<MpUserCache> ignored = mockStatic(MpUserCache.class)) {
return service.updateUser(user);
}
}
private MpUser dbUser() {
MpUser user = new MpUser();
user.setId(USER_ID);
user.setTenantId(TENANT_ID);
user.setPhone(PHONE);
user.setAccount(PHONE);
user.setName("张三");
user.setIdentities("driver");
user.setDeptId(DEPT_ID);
user.setDataScopeRange(1);
user.setDataLevelRange(1);
return user;
}
/**
* range=3(自定义)+ 仅本级,自定义组织由 mpUserDataScopeMapper.selectList 提供
*/
private MpUser customScopeDbUser() {
MpUser user = dbUser();
user.setIdentities("admin");
user.setDataScopeRange(3);
user.setDataLevelRange(2);
return user;
}
private List<MpUserDataScope> scopes(Long... deptIds) {
return Stream.of(deptIds).map(deptId -> {
MpUserDataScope scope = new MpUserDataScope();
scope.setUserId(USER_ID);
scope.setDeptId(deptId);
return scope;
}).toList();
}
private MpUser validUser() {
MpUser user = new MpUser();
user.setId(USER_ID);
user.setTenantId(TENANT_ID);
user.setPhone(PHONE);
user.setIdentities("driver");
user.setDataScopeRange(1);
user.setDataLevelRange(1);
return user;
}
private void assertValidateRejects(Consumer<MpUser> mutation, String expectedMessage) {
MpUser user = validUser();
mutation.accept(user);
ServiceException exception = assertThrows(ServiceException.class, () -> invokeValidate(user));
assertEquals(expectedMessage, exception.getMessage());
}
private void invokeValidate(MpUser user) throws Exception {
Method validate = MpUserServiceImpl.class.getDeclaredMethod("validate", MpUser.class);
validate.setAccessible(true);
try {
validate.invoke(service, user);
} catch (InvocationTargetException exception) {
if (exception.getCause() instanceof RuntimeException runtimeException) {
throw runtimeException;
}
throw exception;
}
}
private void inject(String field, Object value) throws Exception {
Class<?> type = service.getClass();
while (type != null) {
try {
Field declared = type.getDeclaredField(field);
declared.setAccessible(true);
declared.set(service, value);
return;
} catch (NoSuchFieldException ignored) {
type = type.getSuperclass();
}
}
throw new IllegalStateException("未找到字段:" + field);
}
}