|
|
@@ -1,5 +1,6 @@
|
|
|
package com.ruoyi.app.user;
|
|
|
|
|
|
+import com.alibaba.fastjson2.JSON;
|
|
|
import com.ruoyi.common.core.redis.RedisCache;
|
|
|
import com.ruoyi.common.exception.ServiceException;
|
|
|
import com.ruoyi.common.utils.MessageUtils;
|
|
|
@@ -7,6 +8,9 @@ import com.ruoyi.system.domain.InfoUser;
|
|
|
import com.ruoyi.system.mapper.InfoUserMapper;
|
|
|
import org.junit.jupiter.api.Test;
|
|
|
import java.util.Date;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
|
import static org.mockito.Mockito.*;
|
|
|
import static org.mockito.ArgumentMatchers.*;
|
|
|
@@ -15,58 +19,163 @@ class UserCancellationServiceTest {
|
|
|
private final InfoUserMapper users = mock(InfoUserMapper.class);
|
|
|
private final RedisCache redis = mock(RedisCache.class);
|
|
|
private final UserCancellationService service = new UserCancellationService(users, redis);
|
|
|
+ private final Map<String, String> cache = new HashMap<>();
|
|
|
+
|
|
|
+ UserCancellationServiceTest() {
|
|
|
+ doAnswer(call -> { cache.put(call.getArgument(0), call.getArgument(1)); return null; })
|
|
|
+ .when(redis).setCacheObject(anyString(), anyString(), anyInt(), eq(TimeUnit.SECONDS));
|
|
|
+ when(redis.getCacheObject(anyString())).thenAnswer(call -> cache.get(call.getArgument(0)));
|
|
|
+ when(redis.deleteObject(anyString())).thenAnswer(call -> cache.remove(call.getArgument(0)) != null);
|
|
|
+ }
|
|
|
private InfoUser user(String deleted, Date deadline) {
|
|
|
- InfoUser u = new InfoUser(); u.setUserId(1L); u.setUserType("0");
|
|
|
+ InfoUser u = new InfoUser(); u.setUserId(1L); u.setUserType("0"); u.setPhone("0912345678");
|
|
|
u.setStatus("0"); u.setDelFlag(deleted); u.setCancellationDeadlineAt(deadline);
|
|
|
when(users.selectCancellationUserForUpdate(1L)).thenReturn(u); return u;
|
|
|
}
|
|
|
+ private CancellationLoginContext context() {
|
|
|
+ return new CancellationLoginContext(CancellationLoginContext.Flow.PHONE, 1L, "0912345678", "phone", null, null,
|
|
|
+ new CancellationLoginContext.Device("device", "cid", "ios", "push", null, true));
|
|
|
+ }
|
|
|
@Test void cancellationRecordsSevenDaysAndRevokesUserSessions() {
|
|
|
user("0", null); when(users.requestUserCancellation(eq(1L), any())).thenReturn(1);
|
|
|
Date before = new Date(); var result = service.request(1L);
|
|
|
+ assertEquals(7, result.graceDays());
|
|
|
assertTrue(result.cancellationDeadlineAt().getTime() >= before.getTime() + 7L*86400000);
|
|
|
verify(redis).deleteKeys(anyString());
|
|
|
}
|
|
|
- @Test void verifiedLoginRestoresOriginalUserAndClearsDeadline() {
|
|
|
+ @Test void verifiedLoginOnlyReturnsConfirmationAndCeilingDaysWithoutRestoring() {
|
|
|
+ InfoUser u = user("1", new Date(System.currentTimeMillis()+86400000L+60000));
|
|
|
+ var result = service.prepareLogin(context());
|
|
|
+ assertEquals("cancellationConfirm", result.status()); assertEquals(2, result.remainingDays());
|
|
|
+ assertEquals(300, result.expiresInSeconds());
|
|
|
+ assertEquals(u.getCancellationDeadlineAt(), result.cancellationDeadlineAt());
|
|
|
+ assertTrue(result.confirmationKey().matches("[0-9a-f]{32}"));
|
|
|
+ assertEquals("1", u.getDelFlag());
|
|
|
+ verify(users, never()).restoreCancelledUser(anyLong(), any());
|
|
|
+ verify(redis, never()).deleteKeys(anyString());
|
|
|
+ var cached = JSON.parseObject(cache.get(UserCancellationService.CONFIRM_PREFIX + result.confirmationKey()), UserCancellationService.PendingLogin.class);
|
|
|
+ assertEquals(context(), cached.context());
|
|
|
+ }
|
|
|
+ @Test void lessThanOneDayShowsOneDayAndTicketCannotOutliveGrace() {
|
|
|
InfoUser u = user("1", new Date(System.currentTimeMillis()+60000));
|
|
|
- when(users.restoreCancelledUser(eq(1L), any())).thenReturn(1);
|
|
|
- assertSame(u, service.authenticatedLogin(1L));
|
|
|
- assertEquals("0", u.getDelFlag()); assertNull(u.getCancellationDeadlineAt());
|
|
|
+ var result = service.prepareLogin(context());
|
|
|
+ assertEquals(1, result.remainingDays());
|
|
|
+ assertTrue(result.expiresInSeconds() > 0 && result.expiresInSeconds() <= 60);
|
|
|
+ var cached = JSON.parseObject(cache.get(UserCancellationService.CONFIRM_PREFIX + result.confirmationKey()), UserCancellationService.PendingLogin.class);
|
|
|
+ assertEquals(u.getCancellationDeadlineAt().getTime(), cached.expiresAt());
|
|
|
}
|
|
|
- @Test void expiredHistoricalDisabledAndNonUserAccountsCannotRestore() {
|
|
|
+ @Test void confirmationRestoresOriginalAccountAndConsumesCredentialOnce() {
|
|
|
try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
|
|
|
- user("1", null); assertThrows(ServiceException.class, () -> service.authenticatedLogin(1L));
|
|
|
- user("1", new Date(0)); assertThrows(ServiceException.class, () -> service.authenticatedLogin(1L));
|
|
|
- InfoUser u = user("1", new Date(System.currentTimeMillis()+60000)); u.setStatus("1");
|
|
|
- assertThrows(ServiceException.class, () -> service.authenticatedLogin(1L));
|
|
|
- u.setStatus("0"); u.setUserType("2");
|
|
|
- assertThrows(ServiceException.class, () -> service.request(1L));
|
|
|
+ InfoUser u = user("1", new Date(System.currentTimeMillis()+60000));
|
|
|
+ when(users.restoreCancelledUser(eq(1L), any())).thenReturn(1);
|
|
|
+ String key = service.prepareLogin(context()).confirmationKey();
|
|
|
+ var restored = service.confirmLogin(key);
|
|
|
+ assertSame(u, restored.user()); assertEquals(context(), restored.context());
|
|
|
+ assertEquals("0", u.getDelFlag()); assertNull(u.getCancellationDeadlineAt());
|
|
|
+ assertThrows(ServiceException.class, () -> service.confirmLogin(key));
|
|
|
+ verify(users, times(1)).restoreCancelledUser(eq(1L), any());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ @Test void invalidMissingAndExpiredConfirmationCannotRestore() {
|
|
|
+ try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
|
|
|
+ user("1", new Date(System.currentTimeMillis()+60000));
|
|
|
+ for (String key : new String[]{null, "", "abc", "a".repeat(32)})
|
|
|
+ assertThrows(ServiceException.class, () -> service.confirmLogin(key));
|
|
|
+ String key = service.prepareLogin(context()).confirmationKey();
|
|
|
+ String cacheKey = UserCancellationService.CONFIRM_PREFIX + key;
|
|
|
+ var pending = JSON.parseObject(cache.get(cacheKey), UserCancellationService.PendingLogin.class);
|
|
|
+ cache.put(cacheKey, JSON.toJSONString(new UserCancellationService.PendingLogin(context(), pending.cancellationDeadline(), 0, pending.cycle())));
|
|
|
+ assertThrows(ServiceException.class, () -> service.confirmLogin(key));
|
|
|
+ verify(users, never()).restoreCancelledUser(anyLong(), any());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ @Test void staleCancellationCycleChangedPhoneAndDisabledAccountAreRejected() {
|
|
|
+ try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
|
|
|
+ for (String mutation : new String[]{"deadline", "phone", "disabled", "expired", "deleted", "active", "role"}) {
|
|
|
+ InfoUser u = user("1", new Date(System.currentTimeMillis()+60000));
|
|
|
+ String key = service.prepareLogin(context()).confirmationKey();
|
|
|
+ switch(mutation) {
|
|
|
+ case "deadline" -> u.setCancellationDeadlineAt(new Date(u.getCancellationDeadlineAt().getTime()+86400000));
|
|
|
+ case "phone" -> u.setPhone("0999999999");
|
|
|
+ case "disabled" -> u.setStatus("1");
|
|
|
+ case "expired" -> u.setCancellationDeadlineAt(new Date(0));
|
|
|
+ case "deleted" -> u.setCancellationDeadlineAt(null);
|
|
|
+ case "active" -> u.setDelFlag("0");
|
|
|
+ case "role" -> u.setUserType("2");
|
|
|
+ }
|
|
|
+ assertThrows(ServiceException.class, () -> service.confirmLogin(key), mutation);
|
|
|
+ }
|
|
|
+ verify(users, never()).restoreCancelledUser(anyLong(), any());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ @Test void siblingConfirmationCannotRecoverNewCancellationEvenWithSameDeadline() {
|
|
|
+ try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
|
|
|
+ Date deadline = new Date(System.currentTimeMillis()+60000);
|
|
|
+ InfoUser u = user("1", deadline);
|
|
|
+ String first = service.prepareLogin(context()).confirmationKey();
|
|
|
+ String oldSibling = service.prepareLogin(context()).confirmationKey();
|
|
|
+ when(users.restoreCancelledUser(eq(1L), any())).thenReturn(1);
|
|
|
+ service.confirmLogin(first);
|
|
|
+ // 模拟数据库秒精度导致新一次申请的截止时间与上次完全相同。
|
|
|
+ u.setDelFlag("1"); u.setCancellationDeadlineAt(deadline);
|
|
|
+ assertThrows(ServiceException.class, () -> service.confirmLogin(oldSibling));
|
|
|
+ verify(users, times(1)).restoreCancelledUser(eq(1L), any());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ @Test void newCancellationInvalidatesExistingCycleBeforeAnotherPrompt() {
|
|
|
+ try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
|
|
|
+ Date deadline = new Date(System.currentTimeMillis()+60000);
|
|
|
+ InfoUser u = user("1", deadline);
|
|
|
+ String oldKey = service.prepareLogin(context()).confirmationKey();
|
|
|
+ u.setDelFlag("0");
|
|
|
+ when(users.requestUserCancellation(eq(1L), any())).thenReturn(1);
|
|
|
+ service.request(1L);
|
|
|
+ u.setDelFlag("1"); u.setCancellationDeadlineAt(deadline);
|
|
|
+ service.prepareLogin(context());
|
|
|
+ assertThrows(ServiceException.class, () -> service.confirmLogin(oldKey));
|
|
|
verify(users, never()).restoreCancelledUser(anyLong(), any());
|
|
|
}
|
|
|
}
|
|
|
- @Test void updateLosingDeadlineRaceDoesNotRestoreAccount() {
|
|
|
+ @Test void lostCredentialConsumptionRaceCannotRestore() {
|
|
|
try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
|
|
|
user("1", new Date(System.currentTimeMillis()+60000));
|
|
|
- assertThrows(ServiceException.class, () -> service.authenticatedLogin(1L));
|
|
|
+ String key = service.prepareLogin(context()).confirmationKey();
|
|
|
+ when(redis.deleteObject(UserCancellationService.CONFIRM_PREFIX+key)).thenReturn(false);
|
|
|
+ assertThrows(ServiceException.class, () -> service.confirmLogin(key));
|
|
|
+ verify(users, never()).restoreCancelledUser(anyLong(), any());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ @Test void updateLosingDeadlineRaceDoesNotReturnRecoveredAccount() {
|
|
|
+ try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
|
|
|
+ InfoUser u = user("1", new Date(System.currentTimeMillis()+60000));
|
|
|
+ String key = service.prepareLogin(context()).confirmationKey();
|
|
|
+ assertThrows(ServiceException.class, () -> service.confirmLogin(key));
|
|
|
+ assertEquals("1", u.getDelFlag());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ @Test void expiredHistoricalDisabledAndNonUserAccountsCannotPrepareConfirmation() {
|
|
|
+ try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
|
|
|
+ user("1", null); assertThrows(ServiceException.class, () -> service.prepareLogin(context()));
|
|
|
+ user("1", new Date(0)); assertThrows(ServiceException.class, () -> service.prepareLogin(context()));
|
|
|
+ InfoUser u = user("1", new Date(System.currentTimeMillis()+60000)); u.setStatus("1");
|
|
|
+ assertThrows(ServiceException.class, () -> service.prepareLogin(context()));
|
|
|
+ u.setStatus("0"); u.setUserType("2");
|
|
|
+ assertThrows(ServiceException.class, () -> service.prepareLogin(context()));
|
|
|
+ assertThrows(ServiceException.class, () -> service.request(1L));
|
|
|
+ assertTrue(cache.isEmpty());
|
|
|
}
|
|
|
}
|
|
|
@Test void exactDeadlineIsNotRecoverable() {
|
|
|
- Date now = new Date();
|
|
|
- assertFalse(UserCancellationService.canRecover(user("1", now), now));
|
|
|
+ Date now = new Date(); assertFalse(UserCancellationService.canRecover(user("1", now), now));
|
|
|
}
|
|
|
@Test void repeatCancellationCannotExtendDeadline() {
|
|
|
try (var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
|
|
|
user("1", new Date(System.currentTimeMillis() + 60000));
|
|
|
assertThrows(ServiceException.class, () -> service.request(1L));
|
|
|
verify(users, never()).requestUserCancellation(anyLong(), any());
|
|
|
- verifyNoInteractions(redis);
|
|
|
+ verify(redis, never()).deleteKeys(anyString());
|
|
|
}
|
|
|
}
|
|
|
- @Test void activeLoginDoesNotChangeAccountLifecycle() {
|
|
|
- InfoUser active = user("0", null);
|
|
|
- assertSame(active, service.authenticatedLogin(1L));
|
|
|
- verify(users, never()).restoreCancelledUser(anyLong(), any());
|
|
|
- }
|
|
|
-
|
|
|
|
|
|
private com.ruoyi.common.core.domain.entity.SysDictData days(String value) {
|
|
|
var row = new com.ruoyi.common.core.domain.entity.SysDictData();
|
|
|
@@ -113,7 +222,7 @@ class UserCancellationServiceTest {
|
|
|
var deadline = new Date(System.currentTimeMillis() + 60000);
|
|
|
var u = user("1", deadline); when(users.restoreCancelledUser(eq(1L), any())).thenReturn(1);
|
|
|
try (var dict = mockStatic(com.ruoyi.common.utils.DictUtils.class)) {
|
|
|
- assertSame(u, service.authenticatedLogin(1L));
|
|
|
+ assertSame(u, service.confirmLogin(service.prepareLogin(context()).confirmationKey()).user());
|
|
|
dict.verifyNoInteractions();
|
|
|
}
|
|
|
}
|