| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217 |
- package com.ruoyi.app.user;
- import com.baomidou.mybatisplus.core.conditions.Wrapper;
- import com.ruoyi.app.user.dto.CancellationLoginConfirmDto;
- import com.ruoyi.app.user.dto.OAuthBindDto;
- import com.ruoyi.app.user.dto.OAuthDeviceConfirmDto;
- import com.ruoyi.app.user.dto.OAuthLoginDto;
- import com.ruoyi.app.user.service.DeviceTrustService;
- import com.ruoyi.app.utils.oauth.OAuthIdentity;
- import com.ruoyi.app.utils.oauth.OAuthVerifyService;
- import com.ruoyi.common.core.domain.AjaxResult;
- import com.ruoyi.common.core.domain.model.LoginUserDto;
- import com.ruoyi.common.core.redis.RedisCache;
- import com.ruoyi.common.exception.ServiceException;
- import com.ruoyi.common.utils.MessageUtils;
- import com.ruoyi.system.domain.InfoUser;
- import com.ruoyi.system.domain.InfoUserDevice;
- import com.ruoyi.system.domain.InfoUserOauth;
- import com.ruoyi.system.domain.vo.UserDTO;
- import com.ruoyi.system.mapper.InfoUserMapper;
- import com.ruoyi.system.mapper.InfoUserOauthMapper;
- import com.ruoyi.system.service.IInfoUserService;
- import com.ruoyi.system.service.IVipUserService;
- import com.ruoyi.system.utils.JwtUtil;
- import org.junit.jupiter.api.AfterEach;
- import org.junit.jupiter.api.BeforeEach;
- import org.junit.jupiter.api.Test;
- import org.junit.jupiter.params.ParameterizedTest;
- import org.junit.jupiter.params.provider.EnumSource;
- import org.mockito.MockedStatic;
- import org.springframework.mock.web.MockHttpServletRequest;
- import org.springframework.test.util.ReflectionTestUtils;
- import org.springframework.transaction.annotation.Transactional;
- import org.springframework.web.context.request.RequestContextHolder;
- import org.springframework.web.context.request.ServletRequestAttributes;
- import java.util.Date;
- import java.util.HashMap;
- import java.util.List;
- import java.util.Map;
- import java.util.concurrent.TimeUnit;
- import static org.junit.jupiter.api.Assertions.*;
- import static org.mockito.ArgumentMatchers.*;
- import static org.mockito.Mockito.*;
- class CancellationLoginConfirmationTest {
- private final InfoUserController controller = new InfoUserController();
- private final InfoUserMapper accounts = mock(InfoUserMapper.class);
- private final IInfoUserService users = mock(IInfoUserService.class);
- private final RedisCache redis = mock(RedisCache.class);
- private final DeviceTrustService devices = mock(DeviceTrustService.class);
- private final InfoUserOauthMapper bindings = mock(InfoUserOauthMapper.class);
- private final OAuthVerifyService verifier = mock(OAuthVerifyService.class);
- private final Map<String, String> cache = new HashMap<>();
- private final InfoUser user = new InfoUser();
- private final InfoUserOauth binding = new InfoUserOauth();
- private MockedStatic<MessageUtils> messages;
- private MockedStatic<JwtUtil> jwt;
- @BeforeEach void setUp() {
- messages = mockStatic(MessageUtils.class, c -> c.getArgument(0));
- jwt = mockStatic(JwtUtil.class);
- jwt.when(() -> JwtUtil.setToken(anyString(), any(LoginUserDto.class))).thenReturn("new-session");
- var request = new MockHttpServletRequest(); request.setRemoteAddr("127.0.0.1");
- RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
- user.setUserId(7L); user.setUserType("0"); user.setStatus("0"); user.setDelFlag("1");
- user.setPhone("0912345678"); user.setCancellationDeadlineAt(new Date(System.currentTimeMillis() + 3600000));
- binding.setId(77L); binding.setUserId(7L); binding.setProvider("google"); binding.setProviderUid("verified-id");
- when(accounts.selectCancellationUserForUpdate(7L)).thenReturn(user);
- when(accounts.restoreCancelledUser(eq(7L), any())).thenReturn(1);
- when(users.getOne(any(Wrapper.class))).thenReturn(user);
- when(users.list(any(Wrapper.class))).thenReturn(List.of(user));
- when(users.saveOrUpdate(any(InfoUser.class))).thenReturn(true);
- when(bindings.selectOne(any(Wrapper.class))).thenReturn(binding);
- when(bindings.selectByIdForUpdate(77L)).thenReturn(binding);
- when(bindings.insert(any(InfoUserOauth.class))).thenReturn(1);
- when(verifier.verifyIdentity("google", "one-use-code")).thenReturn(new OAuthIdentity("verified-id", "Name"));
- var trust = new InfoUserDevice(); trust.setDeviceId("device"); trust.setPhone(user.getPhone()); trust.setUserId(7L);
- when(devices.getTrust("device")).thenReturn(trust);
- cache.put(user.getPhone(), "123456");
- cache.put("oauth:bind:original", "google@verified-id@Name");
- cache.put("oauth:bind:dev:original", "device"); cache.put("oauth:bind:user:original", "7");
- when(redis.getCacheObject(anyString())).thenAnswer(call -> cache.get(call.getArgument(0)));
- when(redis.deleteObject(anyString())).thenAnswer(call -> cache.remove(call.getArgument(0)) != null);
- doAnswer(call -> { cache.put(call.getArgument(0), call.getArgument(1)); return null; })
- .when(redis).setCacheObject(anyString(), anyString(), anyInt(), eq(TimeUnit.SECONDS));
- ReflectionTestUtils.setField(controller, "userCancellationService", new UserCancellationService(accounts, redis));
- ReflectionTestUtils.setField(controller, "infoUserService", users);
- ReflectionTestUtils.setField(controller, "redisCache", redis);
- ReflectionTestUtils.setField(controller, "deviceTrustService", devices);
- ReflectionTestUtils.setField(controller, "infoUserOauthMapper", bindings);
- ReflectionTestUtils.setField(controller, "oauthVerifyService", verifier);
- ReflectionTestUtils.setField(controller, "vipUserService", mock(IVipUserService.class));
- }
- @AfterEach void tearDown() { jwt.close(); messages.close(); RequestContextHolder.resetRequestAttributes(); }
- private UserCancellationService.LoginConfirmationView prompt(CancellationLoginContext.Flow flow) {
- AjaxResult response;
- switch (flow) {
- case PHONE -> {
- UserDTO dto = new UserDTO(); dto.setPhone(user.getPhone()); dto.setCode("123456");
- dto.setDeviceId("device"); dto.setCid("new-cid");
- response = controller.lodeing(dto);
- }
- case OAUTH -> {
- OAuthLoginDto dto = new OAuthLoginDto(); dto.setProvider("google"); dto.setCredential("one-use-code");
- dto.setDeviceId("device"); dto.setCid("new-cid"); response = controller.oauthLogin(dto);
- }
- case BIND -> {
- OAuthBindDto dto = new OAuthBindDto(); dto.setTempKey("original"); dto.setPhone(user.getPhone());
- dto.setCode("123456"); dto.setDeviceId("device"); dto.setCid("new-cid");
- response = controller.oauthBindPhone(dto);
- }
- default -> {
- OAuthDeviceConfirmDto dto = new OAuthDeviceConfirmDto(); dto.setTempKey("original"); dto.setDeviceId("device");
- response = controller.oauthDeviceConfirm(dto);
- }
- }
- assertEquals(200, response.get("code")); assertNull(response.get("token"));
- var view = (UserCancellationService.LoginConfirmationView) response.get("data");
- assertEquals("cancellationConfirm", view.status()); assertEquals(1, view.remainingDays());
- assertEquals("1", user.getDelFlag()); assertNull(user.getCid());
- verify(accounts, never()).restoreCancelledUser(anyLong(), any());
- verify(users, never()).saveOrUpdate(any(InfoUser.class));
- verify(bindings, never()).insert(any(InfoUserOauth.class));
- verify(bindings, never()).updateById(any(InfoUserOauth.class));
- verify(devices, never()).recordLogin(any(), any(), any());
- verify(devices, never()).recordOauthLogin(any(), any(), any(), any());
- jwt.verifyNoInteractions();
- return view;
- }
- @ParameterizedTest @EnumSource(CancellationLoginContext.Flow.class)
- void everyFlowWaitsForConfirmationAndResumesWithoutReusingOriginalCredential(CancellationLoginContext.Flow flow) {
- var view = prompt(flow);
- cache.remove(user.getPhone()); cache.remove("oauth:bind:original");
- AjaxResult response = controller.confirmCancellationLogin(new CancellationLoginConfirmDto(view.confirmationKey()));
- assertEquals("new-session", response.get("token")); assertSame(user, response.get("data"));
- assertEquals("0", user.getDelFlag()); assertNull(user.getCancellationDeadlineAt());
- if (flow != CancellationLoginContext.Flow.DEVICE) assertEquals("new-cid", user.getCid());
- verify(accounts, times(1)).restoreCancelledUser(eq(7L), any());
- if (flow == CancellationLoginContext.Flow.OAUTH) verify(verifier, times(1)).verifyIdentity(any(), any());
- assertThrows(ServiceException.class, () -> controller.confirmCancellationLogin(new CancellationLoginConfirmDto(view.confirmationKey())));
- jwt.verify(() -> JwtUtil.setToken(anyString(), any(LoginUserDto.class)), times(1));
- }
- @Test void cancellingPromptLeavesLifecycleAndSessionsUnchanged() { prompt(CancellationLoginContext.Flow.PHONE); }
- @Test void failedAuthenticationNeverReachesCancellationPrompt() {
- UserDTO dto = new UserDTO(); dto.setPhone(user.getPhone()); dto.setCode("wrong");
- assertEquals(500, controller.lodeing(dto).get("code"));
- verifyNoInteractions(accounts); jwt.verifyNoInteractions();
- }
- @Test void removedOauthBindingCannotBeRecreatedByConfirmation() {
- var view = prompt(CancellationLoginContext.Flow.OAUTH);
- when(bindings.selectOne(any(Wrapper.class))).thenReturn(null);
- assertThrows(ServiceException.class, () -> controller.confirmCancellationLogin(new CancellationLoginConfirmDto(view.confirmationKey())));
- verify(bindings, never()).insert(any(InfoUserOauth.class)); jwt.verifyNoInteractions();
- }
- @Test void changedTrustedDeviceCannotCompleteConfirmation() {
- var view = prompt(CancellationLoginContext.Flow.DEVICE);
- doThrow(new ServiceException("changed")).when(devices).requireConfirmation("device", 7L);
- assertThrows(ServiceException.class, () -> controller.confirmCancellationLogin(new CancellationLoginConfirmDto(view.confirmationKey())));
- jwt.verifyNoInteractions();
- }
- @Test void tokenFailureThrowsAndConfirmationEndpointHasRollbackTransaction() throws Exception {
- var view = prompt(CancellationLoginContext.Flow.PHONE);
- jwt.when(() -> JwtUtil.setToken(anyString(), any(LoginUserDto.class))).thenReturn(null);
- assertThrows(ServiceException.class, () -> controller.confirmCancellationLogin(new CancellationLoginConfirmDto(view.confirmationKey())));
- var annotation = InfoUserController.class.getMethod("confirmCancellationLogin", CancellationLoginConfirmDto.class)
- .getAnnotation(Transactional.class);
- assertNotNull(annotation); assertTrue(List.of(annotation.rollbackFor()).contains(Exception.class));
- }
- @ParameterizedTest
- @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
- void databaseRecoveryCommitsOnlyWhenLoginSessionSucceeds(boolean failSession) {
- var source = new org.h2.jdbcx.JdbcDataSource();
- source.setURL("jdbc:h2:mem:" + java.util.UUID.randomUUID() + ";DB_CLOSE_DELAY=-1");
- var jdbc = new org.springframework.jdbc.core.JdbcTemplate(source);
- jdbc.execute("CREATE TABLE cancellation_account (id BIGINT PRIMARY KEY, deleted VARCHAR(1), deadline TIMESTAMP)");
- jdbc.update("INSERT INTO cancellation_account VALUES (7, '1', ?)", user.getCancellationDeadlineAt());
- when(accounts.selectCancellationUserForUpdate(7L)).thenAnswer(call -> jdbc.queryForObject(
- "SELECT deleted, deadline FROM cancellation_account WHERE id=7 FOR UPDATE", (row, index) -> {
- InfoUser account = new InfoUser(); account.setUserId(7L); account.setPhone(user.getPhone());
- account.setUserType("0"); account.setStatus("0"); account.setDelFlag(row.getString(1));
- account.setCancellationDeadlineAt(row.getTimestamp(2)); return account;
- }));
- when(accounts.restoreCancelledUser(eq(7L), any())).thenAnswer(call -> jdbc.update(
- "UPDATE cancellation_account SET deleted='0', deadline=NULL WHERE id=7 AND deleted='1' AND deadline>?",
- call.getArgument(1, Date.class)));
- var view = prompt(CancellationLoginContext.Flow.PHONE);
- when(users.getOne(any(Wrapper.class))).thenAnswer(call -> accounts.selectCancellationUserForUpdate(7L));
- var transactionManager = new org.springframework.jdbc.datasource.DataSourceTransactionManager(source);
- var interceptor = new org.springframework.transaction.interceptor.TransactionInterceptor(transactionManager,
- new org.springframework.transaction.annotation.AnnotationTransactionAttributeSource());
- var factory = new org.springframework.aop.framework.ProxyFactory(controller);
- factory.setProxyTargetClass(true); factory.addAdvice(interceptor);
- var transactionalController = (InfoUserController) factory.getProxy();
- if (failSession) {
- jwt.when(() -> JwtUtil.setToken(anyString(), any(LoginUserDto.class))).thenReturn(null);
- assertThrows(ServiceException.class, () -> transactionalController.confirmCancellationLogin(
- new CancellationLoginConfirmDto(view.confirmationKey())));
- assertEquals("1", jdbc.queryForObject("SELECT deleted FROM cancellation_account WHERE id=7", String.class));
- assertNotNull(jdbc.queryForObject("SELECT deadline FROM cancellation_account WHERE id=7", Date.class));
- } else {
- assertEquals("new-session", transactionalController.confirmCancellationLogin(
- new CancellationLoginConfirmDto(view.confirmationKey())).get("token"));
- assertEquals("0", jdbc.queryForObject("SELECT deleted FROM cancellation_account WHERE id=7", String.class));
- assertNull(jdbc.queryForObject("SELECT deadline FROM cancellation_account WHERE id=7", Date.class));
- }
- }
- }
|