Browse Source

fix: make SMS verification limits atomic

integration/lxq-n03-n05-20261008
Zhang Shun 4 weeks ago
parent
commit
bcea6b0295
  1. 23
      mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsFailedAttemptRecorder.java
  2. 6
      mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCodeRepository.java
  3. 12
      mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java
  4. 6
      mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsEmptyPropertyStartupIntegrationTest.java
  5. 112
      mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsFailedAttemptMySqlConcurrencyTest.java
  6. 82
      mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsFailedAttemptPersistenceIntegrationTest.java
  7. 3
      mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsPurposeAllowlistTest.java
  8. 43
      mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsSendRateLimitAtomicityTest.java
  9. 109
      mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsSendRateLimitRedisIntegrationTest.java
  10. 3
      mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsUnavailableSenderRollbackIntegrationTest.java
  11. 1
      mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationAtomicConsumptionTest.java
  12. 10
      mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationSecurityTest.java
  13. 7
      mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java
  14. 8
      mydreamjob-server/src/test/java/com/mydreamjob/controller/AuthIntegrationTest.java
  15. 6
      mydreamjob-server/src/test/java/com/mydreamjob/controller/AuthValidationTest.java

23
mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsFailedAttemptRecorder.java

@ -0,0 +1,23 @@
package com.mydreamjob.auth;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/**
* Persist an invalid-code attempt even when the surrounding login or registration rolls back.
* A conditional database update prevents concurrent guesses from losing increments.
*/
@Service
@RequiredArgsConstructor
public class SmsFailedAttemptRecorder {
private final SmsVerificationCodeRepository codes;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(Long codeId, LocalDateTime now) {
codes.incrementFailedAttemptsIfValid(codeId, now);
}
}

6
mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCodeRepository.java

@ -19,6 +19,12 @@ public interface SmsVerificationCodeRepository extends JpaRepository<SmsVerifica
"AND c.attempts < c.maxAttempts") "AND c.attempts < c.maxAttempts")
int consumeIfValid(@Param("id") Long id, @Param("now") LocalDateTime now); int consumeIfValid(@Param("id") Long id, @Param("now") LocalDateTime now);
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE SmsVerificationCode c SET c.attempts = c.attempts + 1 " +
"WHERE c.id = :id AND c.used = false AND c.expiredAt > :now " +
"AND c.attempts < c.maxAttempts")
int incrementFailedAttemptsIfValid(@Param("id") Long id, @Param("now") LocalDateTime now);
List<SmsVerificationCode> findByPhoneAndCreatedAtAfter(String phone, LocalDateTime after); List<SmsVerificationCode> findByPhoneAndCreatedAtAfter(String phone, LocalDateTime after);
/** /**

12
mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java

@ -34,6 +34,7 @@ public class SmsVerificationService {
private final RedisTemplate<String, Object> redisTemplate; private final RedisTemplate<String, Object> redisTemplate;
private final AuditService auditService; private final AuditService auditService;
private final SmsCodeSender smsCodeSender; private final SmsCodeSender smsCodeSender;
private final SmsFailedAttemptRecorder failedAttemptRecorder;
@Value("${app.sms.mock.code-length:6}") @Value("${app.sms.mock.code-length:6}")
private int codeLength; private int codeLength;
@ -73,9 +74,10 @@ public class SmsVerificationService {
throw new BusinessException(ErrorCode.SMS_RATE_LIMITED); throw new BusinessException(ErrorCode.SMS_RATE_LIMITED);
} }
// 检查频率限制(60秒内最多1次) // Redis SET NX EX 原子占位,防止并发请求同时越过发送频率限制。
String rateLimitKey = RATE_LIMIT_KEY_PREFIX + phone; String rateLimitKey = RATE_LIMIT_KEY_PREFIX + phone;
if (redisTemplate.hasKey(rateLimitKey)) { if (!Boolean.TRUE.equals(redisTemplate.opsForValue()
.setIfAbsent(rateLimitKey, "1", 60, TimeUnit.SECONDS))) {
log.warn("SMS send rejected: rate limited, phone={}", maskPhone(phone)); log.warn("SMS send rejected: rate limited, phone={}", maskPhone(phone));
throw new BusinessException(ErrorCode.SMS_RATE_LIMITED); throw new BusinessException(ErrorCode.SMS_RATE_LIMITED);
} }
@ -96,9 +98,6 @@ public class SmsVerificationService {
.build(); .build();
codeRepository.save(smsCode); codeRepository.save(smsCode);
// 设置频率限制
redisTemplate.opsForValue().set(rateLimitKey, "1", 60, TimeUnit.SECONDS);
// 投递验证码:实现方保证不把验证码/完整手机号写入日志。 // 投递验证码:实现方保证不把验证码/完整手机号写入日志。
// 不可用实现会在此抛出 SERVICE_UNAVAILABLE,令本次事务回滚(不产生虚假的“发送成功”)。 // 不可用实现会在此抛出 SERVICE_UNAVAILABLE,令本次事务回滚(不产生虚假的“发送成功”)。
smsCodeSender.sendVerificationCode(phone, purpose, code); smsCodeSender.sendVerificationCode(phone, purpose, code);
@ -158,8 +157,7 @@ public class SmsVerificationService {
// 校验验证码值 // 校验验证码值
if (!code.equals(smsCode.getCode())) { if (!code.equals(smsCode.getCode())) {
smsCode.setAttempts(smsCode.getAttempts() + 1); failedAttemptRecorder.record(smsCode.getId(), now);
codeRepository.save(smsCode);
throw new BusinessException(ErrorCode.SMS_CODE_INVALID); throw new BusinessException(ErrorCode.SMS_CODE_INVALID);
} }

6
mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsEmptyPropertyStartupIntegrationTest.java

@ -17,6 +17,7 @@ import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@ -53,7 +54,10 @@ class SmsEmptyPropertyStartupIntegrationTest {
void stubRedis() { void stubRedis() {
// 不触发锁定/限流分支,使流程到达发送器调用 // 不触发锁定/限流分支,使流程到达发送器调用
when(redisTemplate.hasKey(anyString())).thenReturn(false); when(redisTemplate.hasKey(anyString())).thenReturn(false);
when(redisTemplate.opsForValue()).thenReturn(mock(ValueOperations.class)); ValueOperations<String, Object> values = mock(ValueOperations.class);
when(values.setIfAbsent(anyString(), eq("1"), eq(60L), eq(java.util.concurrent.TimeUnit.SECONDS)))
.thenReturn(true);
when(redisTemplate.opsForValue()).thenReturn(values);
} }
@Test @Test

112
mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsFailedAttemptMySqlConcurrencyTest.java

@ -0,0 +1,112 @@
package com.mydreamjob.auth;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@Testcontainers(disabledWithoutDocker = false)
@SpringBootTest
@ActiveProfiles("mysql-it")
class SmsFailedAttemptMySqlConcurrencyTest {
private static final String PHONE = "19900009683";
private static final String PURPOSE = "company-register";
@Container
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("sms_attempts_p0")
.withUsername("sms_attempts_test")
.withPassword("sms-attempts-test");
@DynamicPropertySource
static void mysqlProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
}
@Autowired private SmsVerificationService verificationService;
@Autowired private SmsVerificationCodeRepository codes;
@MockBean private RedisTemplate<String, Object> redis;
@AfterEach
void cleanUp() {
codes.deleteAll(codes.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, PURPOSE));
}
@Test
@SuppressWarnings("unchecked")
void concurrentWrongGuessesCannotLoseIncrementsOrExceedConfiguredLimit() throws Exception {
codes.deleteAll(codes.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, PURPOSE));
ValueOperations<String, Object> values = mock(ValueOperations.class);
when(redis.hasKey(anyString())).thenReturn(false);
when(redis.opsForValue()).thenReturn(values);
Long codeId = codes.saveAndFlush(SmsVerificationCode.builder()
.phone(PHONE).purpose(PURPOSE).code("123456")
.attempts(0).maxAttempts(5).used(false)
.expiredAt(LocalDateTime.now().plusMinutes(5)).build()).getId();
int requests = 8;
ExecutorService pool = Executors.newFixedThreadPool(requests);
try {
CountDownLatch ready = new CountDownLatch(requests);
CountDownLatch start = new CountDownLatch(1);
List<Future<Integer>> futures = new ArrayList<>();
for (int i = 0; i < requests; i++) {
futures.add(pool.submit(() -> {
ready.countDown();
assertThat(start.await(10, TimeUnit.SECONDS)).isTrue();
try {
verificationService.verifyCode(PHONE, "000000", PURPOSE);
return -1;
} catch (BusinessException error) {
return error.getErrorCodeInt();
}
}));
}
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
start.countDown();
for (Future<Integer> future : futures) {
assertThat(future.get(30, TimeUnit.SECONDS))
.isIn(ErrorCode.SMS_CODE_INVALID.getCode(),
ErrorCode.SMS_CODE_ATTEMPTS_EXCEEDED.getCode());
}
} finally {
pool.shutdownNow();
}
SmsVerificationCode persisted = codes.findById(codeId).orElseThrow();
assertThat(persisted.getAttempts()).isEqualTo(5);
assertThat(persisted.getUsed()).isFalse();
assertThatThrownBy(() -> verificationService.verifyCode(PHONE, "123456", PURPOSE))
.isInstanceOf(BusinessException.class)
.satisfies(error -> assertThat(((BusinessException) error).getErrorCodeInt())
.isEqualTo(ErrorCode.SMS_CODE_ATTEMPTS_EXCEEDED.getCode()));
}
}

82
mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsFailedAttemptPersistenceIntegrationTest.java

@ -0,0 +1,82 @@
package com.mydreamjob.auth;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/** Exercises the real transactional proxy and database, without a test-level transaction. */
@SpringBootTest
@ActiveProfiles("integration-test")
class SmsFailedAttemptPersistenceIntegrationTest {
private static final String PHONE = "19900009681";
private static final String PURPOSE = "company-register";
@Autowired private SmsVerificationService verificationService;
@Autowired private SmsVerificationCodeRepository codes;
@Autowired private PlatformTransactionManager transactionManager;
@MockBean private RedisTemplate<String, Object> redis;
private Long codeId;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
codes.deleteAll(codes.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, PURPOSE));
ValueOperations<String, Object> values = mock(ValueOperations.class);
when(redis.hasKey(anyString())).thenReturn(false);
when(redis.opsForValue()).thenReturn(values);
codeId = codes.saveAndFlush(SmsVerificationCode.builder()
.phone(PHONE).purpose(PURPOSE).code("123456")
.attempts(0).maxAttempts(5).used(false)
.expiredAt(LocalDateTime.now().plusMinutes(5)).build()).getId();
}
@AfterEach
void cleanUp() {
codes.deleteAll(codes.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, PURPOSE));
}
@Test
void wrongAttemptsPersistDespiteBusinessExceptionAndEventuallyBlockCorrectCode() {
for (int attempt = 1; attempt <= 5; attempt++) {
assertThatThrownBy(() -> verificationService.verifyCode(PHONE, "000000", PURPOSE))
.isInstanceOf(BusinessException.class)
.satisfies(error -> assertThat(((BusinessException) error).getErrorCodeInt())
.isEqualTo(ErrorCode.SMS_CODE_INVALID.getCode()));
assertThat(codes.findById(codeId).orElseThrow().getAttempts()).isEqualTo(attempt);
}
assertThatThrownBy(() -> verificationService.verifyCode(PHONE, "123456", PURPOSE))
.isInstanceOf(BusinessException.class)
.satisfies(error -> assertThat(((BusinessException) error).getErrorCodeInt())
.isEqualTo(ErrorCode.SMS_CODE_ATTEMPTS_EXCEEDED.getCode()));
assertThat(codes.findById(codeId).orElseThrow().getUsed()).isFalse();
}
@Test
void wrongAttemptSurvivesRollbackOfAnOuterRegistrationLikeTransaction() {
TransactionTemplate outer = new TransactionTemplate(transactionManager);
assertThatThrownBy(() -> outer.execute(status ->
verificationService.verifyCode(PHONE, "000000", PURPOSE)))
.isInstanceOf(BusinessException.class);
assertThat(codes.findById(codeId).orElseThrow().getAttempts()).isEqualTo(1);
}
}

3
mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsPurposeAllowlistTest.java

@ -20,7 +20,8 @@ class SmsPurposeAllowlistTest {
@Test @Test
void rejectsArbitraryPurposeBeforeWritingCode() { void rejectsArbitraryPurposeBeforeWritingCode() {
SmsVerificationService service = new SmsVerificationService(codes, redis, audit, sender); SmsVerificationService service = new SmsVerificationService(codes, redis, audit, sender,
org.mockito.Mockito.mock(SmsFailedAttemptRecorder.class));
assertThatThrownBy(() -> service.sendCode("19900000112", "arbitrary-purpose", null, null, null)) assertThatThrownBy(() -> service.sendCode("19900000112", "arbitrary-purpose", null, null, null))
.isInstanceOf(BusinessException.class); .isInstanceOf(BusinessException.class);
} }

43
mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsSendRateLimitAtomicityTest.java

@ -0,0 +1,43 @@
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.Test;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class SmsSendRateLimitAtomicityTest {
@Test
@SuppressWarnings("unchecked")
void losingAtomicRedisReservationNeverPersistsOrSendsAnotherCode() {
SmsVerificationCodeRepository codes = mock(SmsVerificationCodeRepository.class);
RedisTemplate<String, Object> redis = mock(RedisTemplate.class);
ValueOperations<String, Object> values = mock(ValueOperations.class);
SmsCodeSender sender = mock(SmsCodeSender.class);
SmsVerificationService service = new SmsVerificationService(codes, redis,
mock(AuditService.class), sender, mock(SmsFailedAttemptRecorder.class));
when(redis.opsForValue()).thenReturn(values);
when(values.setIfAbsent("sms:rate:19900009682", "1", 60L, TimeUnit.SECONDS))
.thenReturn(false);
assertThatThrownBy(() -> service.sendCode("19900009682", "login", null, null, null))
.isInstanceOf(BusinessException.class)
.satisfies(error -> assertThat(((BusinessException) error).getErrorCodeInt())
.isEqualTo(ErrorCode.SMS_RATE_LIMITED.getCode()));
verify(codes, never()).save(any());
verify(sender, never()).sendVerificationCode(any(), any(), any());
}
}

109
mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsSendRateLimitRedisIntegrationTest.java

@ -0,0 +1,109 @@
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.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.test.util.ReflectionTestUtils;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@Testcontainers(disabledWithoutDocker = false)
class SmsSendRateLimitRedisIntegrationTest {
private static final String PHONE = "19900009684";
private static final String RATE_KEY = "sms:rate:" + PHONE;
@Container
static final GenericContainer<?> REDIS = new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379);
private LettuceConnectionFactory connectionFactory;
private RedisTemplate<String, Object> redis;
@BeforeEach
void setUp() {
connectionFactory = new LettuceConnectionFactory(REDIS.getHost(), REDIS.getMappedPort(6379));
connectionFactory.afterPropertiesSet();
connectionFactory.start();
redis = new RedisTemplate<>();
redis.setConnectionFactory(connectionFactory);
redis.setKeySerializer(new StringRedisSerializer());
redis.setValueSerializer(new GenericJackson2JsonRedisSerializer());
redis.afterPropertiesSet();
}
@AfterEach
void cleanUp() {
redis.delete(RATE_KEY);
connectionFactory.destroy();
}
@Test
void concurrentSendRequestsReserveExactlyOneSixtySecondSlot() throws Exception {
SmsVerificationCodeRepository codes = mock(SmsVerificationCodeRepository.class);
SmsCodeSender sender = mock(SmsCodeSender.class);
SmsVerificationService service = new SmsVerificationService(codes, redis,
mock(AuditService.class), sender, mock(SmsFailedAttemptRecorder.class));
ReflectionTestUtils.setField(service, "codeLength", 6);
ReflectionTestUtils.setField(service, "expireSeconds", 300);
ReflectionTestUtils.setField(service, "maxAttempts", 5);
int requests = 12;
ExecutorService pool = Executors.newFixedThreadPool(requests);
try {
CountDownLatch ready = new CountDownLatch(requests);
CountDownLatch start = new CountDownLatch(1);
List<Future<Integer>> futures = new ArrayList<>();
for (int i = 0; i < requests; i++) {
futures.add(pool.submit(() -> {
ready.countDown();
assertThat(start.await(10, TimeUnit.SECONDS)).isTrue();
try {
service.sendCode(PHONE, "login", null, null, null);
return 0;
} catch (BusinessException error) {
return error.getErrorCodeInt();
}
}));
}
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
start.countDown();
List<Integer> results = new ArrayList<>();
for (Future<Integer> future : futures) {
results.add(future.get(30, TimeUnit.SECONDS));
}
assertThat(results.stream().filter(code -> code == 0).count()).isEqualTo(1);
assertThat(results.stream().filter(code -> code == ErrorCode.SMS_RATE_LIMITED.getCode()).count())
.isEqualTo(requests - 1);
} finally {
pool.shutdownNow();
}
verify(codes, times(1)).save(org.mockito.ArgumentMatchers.any(SmsVerificationCode.class));
verify(sender, times(1)).sendVerificationCode(org.mockito.ArgumentMatchers.eq(PHONE),
org.mockito.ArgumentMatchers.eq("login"), org.mockito.ArgumentMatchers.anyString());
assertThat(redis.getExpire(RATE_KEY, TimeUnit.SECONDS)).isBetween(1L, 60L);
}
}

3
mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsUnavailableSenderRollbackIntegrationTest.java

@ -22,6 +22,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@ -62,6 +63,8 @@ class SmsUnavailableSenderRollbackIntegrationTest {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
void setUp() { void setUp() {
ValueOperations<String, Object> ops = mock(ValueOperations.class); ValueOperations<String, Object> ops = mock(ValueOperations.class);
when(ops.setIfAbsent(anyString(), eq("1"), eq(60L), eq(java.util.concurrent.TimeUnit.SECONDS)))
.thenReturn(true);
when(redisTemplate.opsForValue()).thenReturn(ops); when(redisTemplate.opsForValue()).thenReturn(ops);
when(redisTemplate.hasKey(anyString())).thenReturn(false); when(redisTemplate.hasKey(anyString())).thenReturn(false);
} }

1
mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationAtomicConsumptionTest.java

@ -28,6 +28,7 @@ class SmsVerificationAtomicConsumptionTest {
@Mock ValueOperations<String, Object> values; @Mock ValueOperations<String, Object> values;
@Mock AuditService audit; @Mock AuditService audit;
@Mock SmsCodeSender sender; @Mock SmsCodeSender sender;
@Mock SmsFailedAttemptRecorder failedAttemptRecorder;
@InjectMocks SmsVerificationService service; @InjectMocks SmsVerificationService service;
@BeforeEach @BeforeEach

10
mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationSecurityTest.java

@ -73,6 +73,9 @@ class SmsVerificationSecurityTest {
@Mock @Mock
private SmsCodeSender smsCodeSender; private SmsCodeSender smsCodeSender;
@Mock
private SmsFailedAttemptRecorder failedAttemptRecorder;
@InjectMocks @InjectMocks
private SmsVerificationService service; private SmsVerificationService service;
@ -112,6 +115,8 @@ class SmsVerificationSecurityTest {
private void stubSendAllowed() { private void stubSendAllowed() {
when(redisTemplate.hasKey(anyString())).thenReturn(false); when(redisTemplate.hasKey(anyString())).thenReturn(false);
when(valueOperations.setIfAbsent(anyString(), eq("1"), eq(60L), eq(TimeUnit.SECONDS)))
.thenReturn(true);
when(codeRepository.save(any(SmsVerificationCode.class))) when(codeRepository.save(any(SmsVerificationCode.class)))
.thenAnswer(invocation -> invocation.getArgument(0)); .thenAnswer(invocation -> invocation.getArgument(0));
} }
@ -218,15 +223,12 @@ class SmsVerificationSecurityTest {
when(redisTemplate.hasKey(anyString())).thenReturn(false); when(redisTemplate.hasKey(anyString())).thenReturn(false);
when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE)) when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE))
.thenReturn(Stream.of(stored)); .thenReturn(Stream.of(stored));
ArgumentCaptor<SmsVerificationCode> captor = ArgumentCaptor.forClass(SmsVerificationCode.class);
assertThatThrownBy(() -> service.verifyCode(SENSITIVE_PHONE, "999999", PURPOSE)) assertThatThrownBy(() -> service.verifyCode(SENSITIVE_PHONE, "999999", PURPOSE))
.isInstanceOf(BusinessException.class) .isInstanceOf(BusinessException.class)
.satisfies(e -> assertThat(((BusinessException) e).getErrorCodeInt()) .satisfies(e -> assertThat(((BusinessException) e).getErrorCodeInt())
.isEqualTo(ErrorCode.SMS_CODE_INVALID.getCode())); .isEqualTo(ErrorCode.SMS_CODE_INVALID.getCode()));
verify(codeRepository).save(captor.capture()); verify(failedAttemptRecorder).record(eq(stored.getId()), any(LocalDateTime.class));
assertThat(captor.getValue().getAttempts()).isEqualTo(1);
assertThat(stored.getUsed()).isFalse(); assertThat(stored.getUsed()).isFalse();
} }

7
mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java

@ -39,6 +39,9 @@ class SmsVerificationServiceTest {
@Mock @Mock
private SmsCodeSender smsCodeSender; private SmsCodeSender smsCodeSender;
@Mock
private SmsFailedAttemptRecorder failedAttemptRecorder;
@InjectMocks @InjectMocks
private SmsVerificationService smsVerificationService; private SmsVerificationService smsVerificationService;
@ -50,11 +53,13 @@ class SmsVerificationServiceTest {
@Test @Test
void sendCode_shouldSucceed() { void sendCode_shouldSucceed() {
when(redisTemplate.hasKey(anyString())).thenReturn(false); when(redisTemplate.hasKey(anyString())).thenReturn(false);
when(valueOperations.setIfAbsent(eq("sms:rate:13800000001"), eq("1"), eq(60L), eq(java.util.concurrent.TimeUnit.SECONDS)))
.thenReturn(true);
when(codeRepository.save(any())).thenReturn(null); when(codeRepository.save(any())).thenReturn(null);
assertDoesNotThrow(() -> smsVerificationService.sendCode("13800000001", "login", null, null, null)); assertDoesNotThrow(() -> smsVerificationService.sendCode("13800000001", "login", null, null, null));
verify(codeRepository, times(1)).save(any()); verify(codeRepository, times(1)).save(any());
verify(valueOperations, times(1)).set(anyString(), any(), anyLong(), any()); verify(valueOperations, times(1)).setIfAbsent(anyString(), any(), anyLong(), any());
} }
@Test @Test

8
mydreamjob-server/src/test/java/com/mydreamjob/controller/AuthIntegrationTest.java

@ -86,10 +86,12 @@ class AuthIntegrationTest {
void setUp() { void setUp() {
// Stub Redis 操作:禁用频率限制和锁定 // Stub Redis 操作:禁用频率限制和锁定
when(redisTemplate.hasKey(anyString())).thenReturn(false); when(redisTemplate.hasKey(anyString())).thenReturn(false);
// 使用 CALLS_REAL_METHODS 避免未 stub 方法抛出 NPE // 测试中的 Redis 模拟明确授予原子发送名额。
org.springframework.data.redis.core.ValueOperations<String, Object> ops = org.springframework.data.redis.core.ValueOperations<String, Object> ops =
mock(org.springframework.data.redis.core.ValueOperations.class, mock(org.springframework.data.redis.core.ValueOperations.class);
org.mockito.Answers.CALLS_REAL_METHODS); when(ops.setIfAbsent(anyString(), org.mockito.ArgumentMatchers.eq("1"),
org.mockito.ArgumentMatchers.eq(60L),
org.mockito.ArgumentMatchers.eq(TimeUnit.SECONDS))).thenReturn(true);
when(redisTemplate.opsForValue()).thenReturn(ops); when(redisTemplate.opsForValue()).thenReturn(ops);
codeRepository.deleteAll(); codeRepository.deleteAll();
authTokenRepository.deleteAll(); authTokenRepository.deleteAll();

6
mydreamjob-server/src/test/java/com/mydreamjob/controller/AuthValidationTest.java

@ -39,8 +39,10 @@ class AuthValidationTest {
void setUp() { void setUp() {
when(redisTemplate.hasKey(anyString())).thenReturn(false); when(redisTemplate.hasKey(anyString())).thenReturn(false);
org.springframework.data.redis.core.ValueOperations<String, Object> ops = org.springframework.data.redis.core.ValueOperations<String, Object> ops =
org.mockito.Mockito.mock(org.springframework.data.redis.core.ValueOperations.class, org.mockito.Mockito.mock(org.springframework.data.redis.core.ValueOperations.class);
org.mockito.Answers.CALLS_REAL_METHODS); when(ops.setIfAbsent(anyString(), org.mockito.ArgumentMatchers.eq("1"),
org.mockito.ArgumentMatchers.eq(60L),
org.mockito.ArgumentMatchers.eq(java.util.concurrent.TimeUnit.SECONDS))).thenReturn(true);
when(redisTemplate.opsForValue()).thenReturn(ops); when(redisTemplate.opsForValue()).thenReturn(ops);
} }

Loading…
Cancel
Save