package com.tcm.training.admin.app.auth.service;
|
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
import com.tcm.training.admin.app.auth.dto.AppPasswordLoginRequest;
|
import com.tcm.training.admin.app.auth.dto.AppPasswordResetRequest;
|
import com.tcm.training.admin.app.auth.dto.AppPasswordResetVerifyRequest;
|
import com.tcm.training.admin.app.auth.dto.AppAppleLoginRequest;
|
import com.tcm.training.admin.app.auth.dto.AppSmsCodeSendRequest;
|
import com.tcm.training.admin.app.auth.dto.AppWechatLoginRequest;
|
import com.tcm.training.admin.app.auth.vo.AppLoginResponse;
|
import com.tcm.training.admin.app.auth.vo.AppPasswordResetVerifyResponse;
|
import com.tcm.training.admin.app.auth.vo.AppSmsCodeSendResponse;
|
import com.tcm.training.admin.member.entity.AppUser;
|
import com.tcm.training.admin.member.mapper.UserMapper;
|
import cn.dev33.satoken.stp.SaTokenInfo;
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
import org.junit.jupiter.api.BeforeAll;
|
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.mockito.ArgumentCaptor;
|
import org.mockito.Captor;
|
import org.mockito.Mock;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.ValueOperations;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|
import java.util.concurrent.TimeUnit;
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.anyLong;
|
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.Mockito.doNothing;
|
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.when;
|
|
/**
|
* APP 认证服务测试。
|
*/
|
@ExtendWith(MockitoExtension.class)
|
class AppAuthServiceTest {
|
|
@BeforeAll
|
static void initLambdaCache() {
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
TableInfoHelper.initTableInfo(assistant, AppUser.class);
|
}
|
|
@Mock
|
private UserMapper userMapper;
|
|
@Mock
|
private AppSmsCodeService appSmsCodeService;
|
|
@Mock
|
private BCryptPasswordEncoder passwordEncoder;
|
|
@Mock
|
private AppAuthSessionService appAuthSessionService;
|
|
@Mock
|
private AppWechatAuthClient appWechatAuthClient;
|
|
@Mock
|
private AppAppleAuthClient appAppleAuthClient;
|
|
@Mock
|
private StringRedisTemplate stringRedisTemplate;
|
|
@Mock
|
private ValueOperations<String, String> valueOperations;
|
|
private AppAuthService appAuthService;
|
|
@Captor
|
private ArgumentCaptor<AppUser> appUserCaptor;
|
|
/**
|
* 每个用例前重新构造被测认证服务。
|
*/
|
@BeforeEach
|
void setUp() {
|
appAuthService = new AppAuthService(
|
userMapper,
|
appSmsCodeService,
|
passwordEncoder,
|
appAuthSessionService,
|
appWechatAuthClient,
|
appAppleAuthClient,
|
stringRedisTemplate
|
);
|
}
|
|
/**
|
* 密码登录成功时应返回统一登录响应并建立 APP 登录态。
|
*/
|
@Test
|
void passwordLoginShouldReturnTokenWhenPasswordMatches() {
|
AppPasswordLoginRequest request = new AppPasswordLoginRequest();
|
request.setPhone("13800138000");
|
request.setPassword("abc123!!");
|
|
AppUser user = buildEnabledUser("13800138000", "ENCODED");
|
SaTokenInfo tokenInfo = new SaTokenInfo();
|
tokenInfo.setTokenValue("token-value");
|
tokenInfo.setTokenTimeout(7200L);
|
|
when(userMapper.selectOne(any())).thenReturn(user);
|
when(passwordEncoder.matches("abc123!!", "ENCODED")).thenReturn(true);
|
when(appAuthSessionService.getTokenInfo()).thenReturn(tokenInfo);
|
|
AppLoginResponse response = appAuthService.passwordLogin(request);
|
|
verify(appAuthSessionService).login(1L);
|
assertEquals("token-value", response.getToken());
|
assertEquals("Bearer", response.getTokenType());
|
assertEquals(7200L, response.getExpiresIn());
|
assertEquals(Long.valueOf(1L), response.getUser().getId());
|
assertEquals("13800138000", response.getUser().getPhone());
|
}
|
|
/**
|
* 密码错误时应返回统一错误文案。
|
*/
|
@Test
|
void passwordLoginShouldFailWhenPasswordMismatch() {
|
AppPasswordLoginRequest request = new AppPasswordLoginRequest();
|
request.setPhone("13800138000");
|
request.setPassword("bad-password");
|
|
AppUser user = buildEnabledUser("13800138000", "ENCODED");
|
when(userMapper.selectOne(any())).thenReturn(user);
|
when(passwordEncoder.matches("bad-password", "ENCODED")).thenReturn(false);
|
|
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
|
() -> appAuthService.passwordLogin(request));
|
|
assertEquals("账号或密码错误", exception.getMessage());
|
}
|
|
/**
|
* 忘记密码发码仅允许已启用用户发送。
|
*/
|
@Test
|
void sendPasswordResetCodeShouldReturnExpireSeconds() {
|
AppSmsCodeSendRequest request = new AppSmsCodeSendRequest();
|
request.setPhone("13800138001");
|
|
when(userMapper.selectOne(any())).thenReturn(buildEnabledUser("13800138001", "ENCODED"));
|
when(appSmsCodeService.createPasswordResetCode("13800138001")).thenReturn(new AppSmsCodeSendResponse(300));
|
|
AppSmsCodeSendResponse response = appAuthService.sendPasswordResetCode(request);
|
|
assertEquals(300, response.getExpireInSeconds());
|
}
|
|
/**
|
* 忘记密码验证码校验成功后应签发一次性重置凭证并写入缓存。
|
*/
|
@Test
|
void verifyPasswordResetCodeShouldCacheTicket() {
|
AppPasswordResetVerifyRequest request = new AppPasswordResetVerifyRequest();
|
request.setPhone("13800138002");
|
request.setCode("666666");
|
|
when(userMapper.selectOne(any())).thenReturn(buildEnabledUser("13800138002", "ENCODED"));
|
when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
|
doNothing().when(appSmsCodeService).validatePasswordResetCode("13800138002", "666666");
|
|
AppPasswordResetVerifyResponse response = appAuthService.verifyPasswordResetCode(request);
|
|
assertNotNull(response.getResetTicket());
|
verify(valueOperations).set(anyString(), anyString(), anyLong(), any(TimeUnit.class));
|
}
|
|
/**
|
* 使用有效重置凭证时应更新数据库中的密码并销毁凭证。
|
*/
|
@Test
|
void resetPasswordShouldUpdatePasswordAndDeleteTicket() {
|
AppPasswordResetRequest request = new AppPasswordResetRequest();
|
request.setResetTicket("ticket-1");
|
request.setPassword("abc123!!");
|
request.setConfirmPassword("abc123!!");
|
|
AppUser user = buildEnabledUser("13800138003", "OLD");
|
|
when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
|
when(valueOperations.get(anyString())).thenReturn("13800138003");
|
when(userMapper.selectOne(any())).thenReturn(user);
|
when(passwordEncoder.encode("abc123!!")).thenReturn("NEW-ENCODED");
|
|
appAuthService.resetPassword(request);
|
|
verify(userMapper).updateById(appUserCaptor.capture());
|
verify(stringRedisTemplate).delete(anyString());
|
assertEquals("NEW-ENCODED", appUserCaptor.getValue().getPassword());
|
}
|
|
/**
|
* 重置凭证失效时应拒绝更新密码。
|
*/
|
@Test
|
void resetPasswordShouldFailWhenTicketExpired() {
|
AppPasswordResetRequest request = new AppPasswordResetRequest();
|
request.setResetTicket("expired-ticket");
|
request.setPassword("abc123!!");
|
request.setConfirmPassword("abc123!!");
|
|
when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
|
when(valueOperations.get(anyString())).thenReturn(null);
|
|
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
|
() -> appAuthService.resetPassword(request));
|
|
assertEquals("重置凭证已失效,请重新验证", exception.getMessage());
|
}
|
|
/**
|
* 微信已绑定用户登录时应更新微信资料并返回统一登录响应。
|
*/
|
@Test
|
void wechatLoginShouldReuseExistingUser() {
|
AppWechatLoginRequest request = new AppWechatLoginRequest();
|
request.setAccessToken("access-token");
|
request.setOpenid("openid-1");
|
|
AppWechatUserProfile profile = new AppWechatUserProfile();
|
profile.setOpenid("openid-1");
|
profile.setNickname("微信昵称");
|
profile.setAvatar("https://cdn.example/avatar.png");
|
profile.setSex(2);
|
|
AppUser user = buildEnabledUser("13800138000", "ENCODED");
|
user.setWechatOpenid("openid-1");
|
user.setNickname(null);
|
user.setAvatar(null);
|
|
SaTokenInfo tokenInfo = new SaTokenInfo();
|
tokenInfo.setTokenValue("wechat-token");
|
tokenInfo.setTokenTimeout(7200L);
|
|
when(appWechatAuthClient.resolveUserProfile(request)).thenReturn(profile);
|
when(userMapper.selectOne(any())).thenReturn(user);
|
when(appAuthSessionService.getTokenInfo()).thenReturn(tokenInfo);
|
|
AppLoginResponse response = appAuthService.wechatLogin(request);
|
|
verify(userMapper).updateById(appUserCaptor.capture());
|
verify(userMapper, never()).insert(any());
|
assertEquals("openid-1", appUserCaptor.getValue().getWechatOpenid());
|
assertEquals("微信昵称", appUserCaptor.getValue().getNickname());
|
assertEquals("https://cdn.example/avatar.png", appUserCaptor.getValue().getAvatar());
|
assertEquals(Integer.valueOf(0), appUserCaptor.getValue().getGender());
|
assertEquals("微信昵称", response.getUser().getNickname());
|
assertEquals("https://cdn.example/avatar.png", response.getUser().getAvatar());
|
}
|
|
/**
|
* 微信首次登录时应自动创建普通用户并返回统一登录响应。
|
*/
|
@Test
|
void wechatLoginShouldCreateUserWhenOpenidNotExists() {
|
AppWechatLoginRequest request = new AppWechatLoginRequest();
|
request.setAccessToken("access-token");
|
request.setOpenid("openid-new");
|
|
AppWechatUserProfile profile = new AppWechatUserProfile();
|
profile.setOpenid("openid-new");
|
profile.setNickname("新微信用户");
|
profile.setAvatar("https://cdn.example/new-avatar.png");
|
profile.setSex(1);
|
|
SaTokenInfo tokenInfo = new SaTokenInfo();
|
tokenInfo.setTokenValue("new-token");
|
tokenInfo.setTokenTimeout(7200L);
|
|
when(appWechatAuthClient.resolveUserProfile(request)).thenReturn(profile);
|
when(userMapper.selectOne(any())).thenReturn(null);
|
when(appAuthSessionService.getTokenInfo()).thenReturn(tokenInfo);
|
|
AppLoginResponse response = appAuthService.wechatLogin(request);
|
|
verify(userMapper).insert(appUserCaptor.capture());
|
verify(userMapper, never()).updateById(any());
|
assertNull(appUserCaptor.getValue().getPhone());
|
assertEquals("openid-new", appUserCaptor.getValue().getWechatOpenid());
|
assertEquals("新微信用户", appUserCaptor.getValue().getNickname());
|
assertEquals("https://cdn.example/new-avatar.png", appUserCaptor.getValue().getAvatar());
|
assertEquals(Integer.valueOf(1), appUserCaptor.getValue().getGender());
|
assertEquals(Integer.valueOf(0), appUserCaptor.getValue().getType());
|
assertEquals("new-token", response.getToken());
|
}
|
|
/**
|
* 苹果已绑定用户登录时应更新苹果绑定信息并返回统一登录响应。
|
*/
|
@Test
|
void appleLoginShouldReuseExistingUser() {
|
AppAppleLoginRequest request = new AppAppleLoginRequest();
|
request.setIdentityToken("identity-token");
|
request.setUserIdentifier("apple-user-1");
|
|
AppAppleUserProfile profile = new AppAppleUserProfile();
|
profile.setSubject("apple-subject-1");
|
profile.setUserIdentifier("apple-user-1");
|
|
AppUser user = buildEnabledUser("13800138009", "ENCODED");
|
user.setAppleIdentitySubject("apple-subject-1");
|
user.setAppleUserIdentifier("apple-user-legacy");
|
user.setNickname("已存在用户");
|
|
SaTokenInfo tokenInfo = new SaTokenInfo();
|
tokenInfo.setTokenValue("apple-token");
|
tokenInfo.setTokenTimeout(7200L);
|
|
when(appAppleAuthClient.resolveUserProfile(request)).thenReturn(profile);
|
when(userMapper.selectOne(any())).thenReturn(user);
|
when(appAuthSessionService.getTokenInfo()).thenReturn(tokenInfo);
|
|
AppLoginResponse response = appAuthService.appleLogin(request);
|
|
verify(userMapper).updateById(appUserCaptor.capture());
|
verify(userMapper, never()).insert(any());
|
assertEquals("apple-subject-1", appUserCaptor.getValue().getAppleIdentitySubject());
|
assertEquals("apple-user-1", appUserCaptor.getValue().getAppleUserIdentifier());
|
assertEquals("已存在用户", response.getUser().getNickname());
|
assertEquals("apple-token", response.getToken());
|
}
|
|
/**
|
* 苹果首次登录时应自动创建普通用户并等待后续绑定手机号。
|
*/
|
@Test
|
void appleLoginShouldCreateUserWhenSubjectNotExists() {
|
AppAppleLoginRequest request = new AppAppleLoginRequest();
|
request.setIdentityToken("identity-token");
|
request.setUserIdentifier("apple-user-new");
|
|
AppAppleUserProfile profile = new AppAppleUserProfile();
|
profile.setSubject("apple-subject-new");
|
profile.setUserIdentifier("apple-user-new");
|
|
SaTokenInfo tokenInfo = new SaTokenInfo();
|
tokenInfo.setTokenValue("apple-new-token");
|
tokenInfo.setTokenTimeout(7200L);
|
|
when(appAppleAuthClient.resolveUserProfile(request)).thenReturn(profile);
|
when(userMapper.selectOne(any())).thenReturn(null);
|
when(appAuthSessionService.getTokenInfo()).thenReturn(tokenInfo);
|
|
AppLoginResponse response = appAuthService.appleLogin(request);
|
|
verify(userMapper).insert(appUserCaptor.capture());
|
verify(userMapper, never()).updateById(any());
|
assertNull(appUserCaptor.getValue().getPhone());
|
assertEquals("apple-subject-new", appUserCaptor.getValue().getAppleIdentitySubject());
|
assertEquals("apple-user-new", appUserCaptor.getValue().getAppleUserIdentifier());
|
assertEquals("苹果用户", appUserCaptor.getValue().getNickname());
|
assertEquals(Integer.valueOf(0), appUserCaptor.getValue().getType());
|
assertEquals("apple-new-token", response.getToken());
|
}
|
|
/**
|
* 苹果授权无效时应直接拒绝登录。
|
*/
|
@Test
|
void appleLoginShouldFailWhenIdentityTokenInvalid() {
|
AppAppleLoginRequest request = new AppAppleLoginRequest();
|
request.setIdentityToken("bad-token");
|
request.setUserIdentifier("apple-user-bad");
|
|
when(appAppleAuthClient.resolveUserProfile(request)).thenThrow(new IllegalArgumentException("苹果登录授权失败"));
|
|
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
|
() -> appAuthService.appleLogin(request));
|
|
assertEquals("苹果登录授权失败", exception.getMessage());
|
}
|
|
/**
|
* 统一构造启用中的 APP 用户测试桩。
|
*/
|
private AppUser buildEnabledUser(String phone, String password) {
|
AppUser user = new AppUser();
|
user.setId(1L);
|
user.setPhone(phone);
|
user.setPassword(password);
|
user.setNickname("测试用户");
|
user.setStatus(1);
|
user.setType(0);
|
return user;
|
}
|
}
|