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 cache = new HashMap<>(); private final InfoUser user = new InfoUser(); private final InfoUserOauth binding = new InfoUserOauth(); private MockedStatic messages; private MockedStatic 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)); } } }