feat(aihr): harden M0-M2 mobile training flows

This commit is contained in:
2026-07-23 21:18:13 +08:00
parent 06322c994b
commit 927458b9e0
44 changed files with 1674 additions and 119 deletions
@@ -0,0 +1,82 @@
package org.dromara.web.controller;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.SmsCodeUtils;
import org.dromara.web.domain.vo.LoginVo;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.redisson.api.RBucket;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@Tag("dev")
class MobileSmsLoginTenantBoundaryTest {
@Test
void mobileLoginIgnoresClientTenantSelection() throws Exception {
AuthController.MobileSmsLoginBody body = new ObjectMapper().readValue("""
{"phonenumber":"13900000000","smsCode":"123456","tenantId":"tenant-b"}
""", AuthController.MobileSmsLoginBody.class);
assertEquals("13900000000", body.phonenumber());
assertEquals("123456", body.smsCode());
assertEquals("000000", AuthController.mobileTenantId());
}
@Test
@SuppressWarnings("unchecked")
void resendingSmsUsesTheSameLockAsVerification() throws Exception {
RedissonClient redisClient = mock(RedissonClient.class);
RLock lock = mock(RLock.class);
RBucket<String> bucket = mock(RBucket.class);
String tenantId = "000000";
String phonenumber = "13900000000";
when(redisClient.getLock(SmsCodeUtils.verificationLockKey(tenantId, phonenumber))).thenReturn(lock);
when(redisClient.<String>getBucket(anyString())).thenReturn(bucket);
when(lock.tryLock(2, 5, TimeUnit.SECONDS)).thenReturn(true);
when(lock.isHeldByCurrentThread()).thenReturn(true);
CaptchaController.storeSmsCode(redisClient, tenantId, phonenumber, "123456");
verify(bucket).set("123456", Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
verify(lock).unlock();
}
@Test
void mobileLoginResponseUsesTheH5AuthContractOnly() throws Exception {
LoginVo loginVo = new LoginVo();
loginVo.setAccessToken("test-access-token");
loginVo.setClientId("test-mobile-client");
loginVo.setRefreshToken("must-not-be-returned");
JsonNode json = new ObjectMapper().readTree(new ObjectMapper().writeValueAsString(
AuthController.MobileSmsLoginResponse.from(loginVo)
));
assertEquals("test-access-token", json.path("access_token").asText());
assertEquals("test-mobile-client", json.path("client_id").asText());
assertFalse(json.has("refresh_token"));
}
@Test
void mobileLoginResponseRejectsAnIncompleteStrategyResult() {
LoginVo loginVo = new LoginVo();
loginVo.setClientId("test-mobile-client");
assertThrows(ServiceException.class, () -> AuthController.MobileSmsLoginResponse.from(loginVo));
}
}
@@ -1,25 +1,99 @@
package org.dromara.web.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.dromara.common.core.enums.UserType;
import org.dromara.common.core.utils.SmsCodeUtils;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.dromara.system.domain.SysUser;
import org.dromara.system.domain.vo.SysClientVo;
import org.dromara.system.domain.vo.SysUserVo;
import org.dromara.system.mapper.SysUserMapper;
import org.dromara.web.service.SysLoginService;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.redisson.api.RBucket;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@Tag("dev")
class SmsAuthStrategyTenantTest {
@Test
void mobileSmsUserLookupKeepsTenantBoundary() throws Exception {
Path source = Path.of("src/main/java/org/dromara/web/service/impl/SmsAuthStrategy.java");
if (!Files.exists(source)) {
source = Path.of("ruoyi-admin/src/main/java/org/dromara/web/service/impl/SmsAuthStrategy.java");
}
String code = Files.readString(source);
private static final String TEST_MOBILE = "13900000000";
assertTrue(code.contains(".eq(SysUser::getTenantId, tenantId)"));
assertTrue(code.contains(".eq(SysUser::getPhonenumber, phonenumber)"));
@BeforeAll
static void initializeSysUserLambdaMetadata() {
if (TableInfoHelper.getTableInfo(SysUser.class) == null) {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), SysUser.class);
}
}
@Test
void mobileSmsLoginUsesAppUsersAndRejectsAConflictingSystemUser() {
SysLoginService loginService = mock(SysLoginService.class);
SysUserMapper userMapper = mock(SysUserMapper.class);
SmsAuthStrategy strategy = new SmsAuthStrategy(loginService, userMapper);
when(userMapper.selectVoOne(any())).thenReturn(null);
when(userMapper.exists(any())).thenReturn(true);
assertThrows(RuntimeException.class,
() -> strategy.loadOrRegisterUserByPhonenumber("000000", TEST_MOBILE, true));
verify(userMapper, never()).insert(any(SysUser.class));
SysClientVo appClient = new SysClientVo();
appClient.setClientId(SmsCodeUtils.MOBILE_CLIENT_ID);
assertTrue(SmsAuthStrategy.isAppClient(appClient));
}
@Test
void existingAppUserIsRetainedAndWrongOtpDoesNotConsumeTheCode() throws Exception {
SysLoginService loginService = mock(SysLoginService.class);
SysUserMapper userMapper = mock(SysUserMapper.class);
SmsAuthStrategy strategy = new SmsAuthStrategy(loginService, userMapper);
SysUserVo appUser = new SysUserVo();
appUser.setUserType(UserType.APP_USER.getUserType());
appUser.setStatus("0");
when(userMapper.selectVoOne(any())).thenReturn(appUser);
assertSame(appUser, strategy.loadOrRegisterUserByPhonenumber("000000", TEST_MOBILE, true));
RedissonClient redisClient = mock(RedissonClient.class);
RLock lock = mock(RLock.class);
@SuppressWarnings("unchecked")
RBucket<String> bucket = mock(RBucket.class);
when(redisClient.getLock(SmsCodeUtils.verificationLockKey("000000", TEST_MOBILE))).thenReturn(lock);
when(redisClient.<String>getBucket(anyString())).thenReturn(bucket);
when(lock.tryLock(eq(2L), eq(5L), eq(TimeUnit.SECONDS))).thenReturn(true);
when(lock.isHeldByCurrentThread()).thenReturn(true);
when(bucket.get()).thenReturn("123456");
assertFalse(strategy.validateSmsCode(redisClient, "000000", TEST_MOBILE, "000000"));
verify(bucket, never()).delete();
verify(lock).unlock();
assertTrue(strategy.validateSmsCode(redisClient, "000000", TEST_MOBILE, "123456"));
verify(bucket).delete();
}
@Test
void mobileSmsLoginRejectsAConflictingSystemUserEvenWhenAnAppUserExists() {
SysLoginService loginService = mock(SysLoginService.class);
SysUserMapper userMapper = mock(SysUserMapper.class);
SmsAuthStrategy strategy = new SmsAuthStrategy(loginService, userMapper);
SysUserVo appUser = new SysUserVo();
appUser.setUserType(UserType.APP_USER.getUserType());
when(userMapper.exists(any())).thenReturn(true);
when(userMapper.selectVoOne(any())).thenReturn(appUser);
assertThrows(RuntimeException.class,
() -> strategy.loadOrRegisterUserByPhonenumber("000000", TEST_MOBILE, true));
verify(userMapper, never()).selectVoOne(any());
verify(userMapper, never()).insert(any(SysUser.class));
}
}