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.app.share.service.AppShareService; 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 AppShareService appShareService; @Mock private StringRedisTemplate stringRedisTemplate; @Mock private ValueOperations valueOperations; private AppAuthService appAuthService; @Captor private ArgumentCaptor appUserCaptor; /** * 每个用例前重新构造被测认证服务。 */ @BeforeEach void setUp() { appAuthService = new AppAuthService( userMapper, appSmsCodeService, passwordEncoder, appAuthSessionService, appWechatAuthClient, appAppleAuthClient, appShareService, 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; } }