You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
170 lines
7.3 KiB
170 lines
7.3 KiB
package com.mydreamjob.auth;
|
|
|
|
import com.mydreamjob.audit.AuditService;
|
|
import com.mydreamjob.auth.sms.SmsCodeSender;
|
|
import com.mydreamjob.common.exception.BusinessException;
|
|
import com.mydreamjob.common.exception.ErrorCode;
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.junit.jupiter.api.extension.ExtendWith;
|
|
import org.mockito.InjectMocks;
|
|
import org.mockito.Mock;
|
|
import org.mockito.junit.jupiter.MockitoExtension;
|
|
import org.springframework.data.redis.core.RedisTemplate;
|
|
import org.springframework.data.redis.core.ValueOperations;
|
|
|
|
import java.time.LocalDateTime;
|
|
import java.util.List;
|
|
import java.util.Optional;
|
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
import static org.mockito.ArgumentMatchers.*;
|
|
import static org.mockito.Mockito.*;
|
|
|
|
@ExtendWith(MockitoExtension.class)
|
|
class SmsVerificationServiceTest {
|
|
|
|
@Mock
|
|
private SmsVerificationCodeRepository codeRepository;
|
|
|
|
@Mock
|
|
private RedisTemplate<String, Object> redisTemplate;
|
|
|
|
@Mock
|
|
private ValueOperations<String, Object> valueOperations;
|
|
|
|
@Mock
|
|
private AuditService auditService;
|
|
|
|
@Mock
|
|
private SmsCodeSender smsCodeSender;
|
|
|
|
@InjectMocks
|
|
private SmsVerificationService smsVerificationService;
|
|
|
|
@BeforeEach
|
|
void setUp() {
|
|
lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
|
}
|
|
|
|
@Test
|
|
void sendCode_shouldSucceed() {
|
|
when(redisTemplate.hasKey(anyString())).thenReturn(false);
|
|
when(codeRepository.save(any())).thenReturn(null);
|
|
|
|
assertDoesNotThrow(() -> smsVerificationService.sendCode("13800000001", "login", null, null, null));
|
|
verify(codeRepository, times(1)).save(any());
|
|
verify(valueOperations, times(1)).set(anyString(), any(), anyLong(), any());
|
|
}
|
|
|
|
@Test
|
|
void sendCode_shouldThrowWhenRateLimited() {
|
|
when(redisTemplate.hasKey(eq("sms:lockout:13800000001"))).thenReturn(true);
|
|
|
|
BusinessException ex = assertThrows(BusinessException.class,
|
|
() -> smsVerificationService.sendCode("13800000001", "login", null, null, null));
|
|
assertEquals(ErrorCode.SMS_RATE_LIMITED.getCode(), ex.getErrorCodeInt());
|
|
}
|
|
|
|
// ---- verifyCode tests ----
|
|
|
|
@Test
|
|
void verifyCode_success_withPurpose() {
|
|
SmsVerificationCode code = buildCode("13800000001", "123456", "login", false);
|
|
when(redisTemplate.hasKey(anyString())).thenReturn(false);
|
|
when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login"))
|
|
.thenReturn(java.util.stream.Stream.of(code));
|
|
when(codeRepository.consumeIfValid(any(), any())).thenReturn(1);
|
|
|
|
assertDoesNotThrow(() -> smsVerificationService.verifyCode("13800000001", "123456", "login"));
|
|
verify(codeRepository, times(1)).consumeIfValid(any(), any());
|
|
}
|
|
|
|
@Test
|
|
void verifyCode_replayMustFail_afterUsed() {
|
|
SmsVerificationCode code = buildCode("13800000001", "123456", "login", false);
|
|
when(redisTemplate.hasKey(anyString())).thenReturn(false);
|
|
// 第一次验证通过后 used=true
|
|
when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login"))
|
|
.thenReturn(java.util.stream.Stream.of(code));
|
|
when(codeRepository.consumeIfValid(any(), any())).thenReturn(1);
|
|
|
|
// 第一次成功
|
|
smsVerificationService.verifyCode("13800000001", "123456", "login");
|
|
|
|
// 第二次重放:已使用的验证码不应再查到(used=true 的不在查询结果中)
|
|
when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login"))
|
|
.thenReturn(java.util.stream.Stream.empty());
|
|
|
|
BusinessException ex = assertThrows(BusinessException.class,
|
|
() -> smsVerificationService.verifyCode("13800000001", "123456", "login"));
|
|
assertEquals(ErrorCode.SMS_CODE_INVALID.getCode(), ex.getErrorCodeInt());
|
|
}
|
|
|
|
@Test
|
|
void verifyCode_purposeMismatchMustFail() {
|
|
SmsVerificationCode code = buildCode("13800000001", "123456", "login", false);
|
|
when(redisTemplate.hasKey(anyString())).thenReturn(false);
|
|
// 查询 password-reset 用途,但实际只有 login 的记录
|
|
when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "password-reset"))
|
|
.thenReturn(java.util.stream.Stream.empty());
|
|
|
|
BusinessException ex = assertThrows(BusinessException.class,
|
|
() -> smsVerificationService.verifyCode("13800000001", "123456", "password-reset"));
|
|
assertEquals(ErrorCode.SMS_CODE_INVALID.getCode(), ex.getErrorCodeInt());
|
|
}
|
|
|
|
@Test
|
|
void verifyCode_usedCodeMustFail_directly() {
|
|
SmsVerificationCode code = buildCode("13800000001", "123456", "login", true);
|
|
when(redisTemplate.hasKey(anyString())).thenReturn(false);
|
|
when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login"))
|
|
.thenReturn(java.util.stream.Stream.empty());
|
|
|
|
BusinessException ex = assertThrows(BusinessException.class,
|
|
() -> smsVerificationService.verifyCode("13800000001", "123456", "login"));
|
|
assertEquals(ErrorCode.SMS_CODE_INVALID.getCode(), ex.getErrorCodeInt());
|
|
}
|
|
|
|
@Test
|
|
void verifyCode_latestPriority_returnsNewestOnly() {
|
|
LocalDateTime oldTime = LocalDateTime.now().minusMinutes(5);
|
|
LocalDateTime newTime = LocalDateTime.now().minusMinutes(1);
|
|
|
|
SmsVerificationCode oldCode = SmsVerificationCode.builder()
|
|
.phone("13800000001").code("999999").purpose("login")
|
|
.expiredAt(newTime.plusMinutes(5)).attempts(0).used(false).createdAt(oldTime).build();
|
|
SmsVerificationCode newCode = SmsVerificationCode.builder()
|
|
.phone("13800000001").code("123456").purpose("login")
|
|
.expiredAt(newTime.plusMinutes(5)).attempts(0).used(false).createdAt(newTime).build();
|
|
|
|
when(redisTemplate.hasKey(anyString())).thenReturn(false);
|
|
// repository 按 createdAt DESC 返回,第一个是最新的
|
|
when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login"))
|
|
.thenReturn(java.util.stream.Stream.of(newCode));
|
|
when(codeRepository.consumeIfValid(any(), any())).thenReturn(1);
|
|
|
|
assertDoesNotThrow(() -> smsVerificationService.verifyCode("13800000001", "123456", "login"));
|
|
// 旧验证码 999999 不应被验证通过
|
|
verify(codeRepository, never()).save(oldCode);
|
|
}
|
|
|
|
@Test
|
|
void verifyCode_noRecordMustFail() {
|
|
when(redisTemplate.hasKey(anyString())).thenReturn(false);
|
|
when(codeRepository.streamLatestUnusedByPhoneAndPurpose(any(), any()))
|
|
.thenReturn(java.util.stream.Stream.empty());
|
|
|
|
BusinessException ex = assertThrows(BusinessException.class,
|
|
() -> smsVerificationService.verifyCode("13800000001", "123456", "login"));
|
|
assertEquals(ErrorCode.SMS_CODE_INVALID.getCode(), ex.getErrorCodeInt());
|
|
}
|
|
|
|
private SmsVerificationCode buildCode(String phone, String code, String purpose, boolean used) {
|
|
return SmsVerificationCode.builder()
|
|
.phone(phone).code(code).purpose(purpose)
|
|
.expiredAt(LocalDateTime.now().plusMinutes(5))
|
|
.attempts(0).maxAttempts(5).used(used)
|
|
.createdAt(LocalDateTime.now()).build();
|
|
}
|
|
}
|
|
|