From bcea6b02955f20f933572e8c8f2c85b1ad05843c Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Mon, 28 Sep 2026 17:24:35 +0800 Subject: [PATCH] fix: make SMS verification limits atomic --- .../auth/SmsFailedAttemptRecorder.java | 23 ++++ .../auth/SmsVerificationCodeRepository.java | 6 + .../auth/SmsVerificationService.java | 12 +- ...msEmptyPropertyStartupIntegrationTest.java | 6 +- .../SmsFailedAttemptMySqlConcurrencyTest.java | 112 ++++++++++++++++++ ...iledAttemptPersistenceIntegrationTest.java | 82 +++++++++++++ .../auth/SmsPurposeAllowlistTest.java | 3 +- .../auth/SmsSendRateLimitAtomicityTest.java | 43 +++++++ .../SmsSendRateLimitRedisIntegrationTest.java | 109 +++++++++++++++++ ...vailableSenderRollbackIntegrationTest.java | 3 + .../SmsVerificationAtomicConsumptionTest.java | 1 + .../auth/SmsVerificationSecurityTest.java | 10 +- .../auth/SmsVerificationServiceTest.java | 7 +- .../controller/AuthIntegrationTest.java | 8 +- .../controller/AuthValidationTest.java | 6 +- 15 files changed, 412 insertions(+), 19 deletions(-) create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsFailedAttemptRecorder.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsFailedAttemptMySqlConcurrencyTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsFailedAttemptPersistenceIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsSendRateLimitAtomicityTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsSendRateLimitRedisIntegrationTest.java diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsFailedAttemptRecorder.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsFailedAttemptRecorder.java new file mode 100644 index 0000000..63ad1e1 --- /dev/null +++ b/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); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCodeRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCodeRepository.java index 7ab0c72..443b65f 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCodeRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCodeRepository.java @@ -19,6 +19,12 @@ public interface SmsVerificationCodeRepository extends JpaRepository :now " + + "AND c.attempts < c.maxAttempts") + int incrementFailedAttemptsIfValid(@Param("id") Long id, @Param("now") LocalDateTime now); + List findByPhoneAndCreatedAtAfter(String phone, LocalDateTime after); /** diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java index c2d0723..c7924b3 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java @@ -34,6 +34,7 @@ public class SmsVerificationService { private final RedisTemplate redisTemplate; private final AuditService auditService; private final SmsCodeSender smsCodeSender; + private final SmsFailedAttemptRecorder failedAttemptRecorder; @Value("${app.sms.mock.code-length:6}") private int codeLength; @@ -73,9 +74,10 @@ public class SmsVerificationService { throw new BusinessException(ErrorCode.SMS_RATE_LIMITED); } - // 检查频率限制(60秒内最多1次) + // Redis SET NX EX 原子占位,防止并发请求同时越过发送频率限制。 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)); throw new BusinessException(ErrorCode.SMS_RATE_LIMITED); } @@ -96,9 +98,6 @@ public class SmsVerificationService { .build(); codeRepository.save(smsCode); - // 设置频率限制 - redisTemplate.opsForValue().set(rateLimitKey, "1", 60, TimeUnit.SECONDS); - // 投递验证码:实现方保证不把验证码/完整手机号写入日志。 // 不可用实现会在此抛出 SERVICE_UNAVAILABLE,令本次事务回滚(不产生虚假的“发送成功”)。 smsCodeSender.sendVerificationCode(phone, purpose, code); @@ -158,8 +157,7 @@ public class SmsVerificationService { // 校验验证码值 if (!code.equals(smsCode.getCode())) { - smsCode.setAttempts(smsCode.getAttempts() + 1); - codeRepository.save(smsCode); + failedAttemptRecorder.record(smsCode.getId(), now); throw new BusinessException(ErrorCode.SMS_CODE_INVALID); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsEmptyPropertyStartupIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsEmptyPropertyStartupIntegrationTest.java index 13db68f..617b33a 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsEmptyPropertyStartupIntegrationTest.java +++ b/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.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -53,7 +54,10 @@ class SmsEmptyPropertyStartupIntegrationTest { void stubRedis() { // 不触发锁定/限流分支,使流程到达发送器调用 when(redisTemplate.hasKey(anyString())).thenReturn(false); - when(redisTemplate.opsForValue()).thenReturn(mock(ValueOperations.class)); + ValueOperations 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 diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsFailedAttemptMySqlConcurrencyTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsFailedAttemptMySqlConcurrencyTest.java new file mode 100644 index 0000000..433ded2 --- /dev/null +++ b/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 redis; + + @AfterEach + void cleanUp() { + codes.deleteAll(codes.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, PURPOSE)); + } + + @Test + @SuppressWarnings("unchecked") + void concurrentWrongGuessesCannotLoseIncrementsOrExceedConfiguredLimit() throws Exception { + codes.deleteAll(codes.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, PURPOSE)); + ValueOperations 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> 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 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())); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsFailedAttemptPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsFailedAttemptPersistenceIntegrationTest.java new file mode 100644 index 0000000..71e777d --- /dev/null +++ b/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 redis; + + private Long codeId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + codes.deleteAll(codes.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, PURPOSE)); + ValueOperations 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); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsPurposeAllowlistTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsPurposeAllowlistTest.java index ae205c8..7e75442 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsPurposeAllowlistTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsPurposeAllowlistTest.java @@ -20,7 +20,8 @@ class SmsPurposeAllowlistTest { @Test 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)) .isInstanceOf(BusinessException.class); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsSendRateLimitAtomicityTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsSendRateLimitAtomicityTest.java new file mode 100644 index 0000000..563818b --- /dev/null +++ b/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 redis = mock(RedisTemplate.class); + ValueOperations 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()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsSendRateLimitRedisIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsSendRateLimitRedisIntegrationTest.java new file mode 100644 index 0000000..52c595c --- /dev/null +++ b/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 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> 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 results = new ArrayList<>(); + for (Future 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); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsUnavailableSenderRollbackIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsUnavailableSenderRollbackIntegrationTest.java index 8f96813..85337b4 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsUnavailableSenderRollbackIntegrationTest.java +++ b/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.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -62,6 +63,8 @@ class SmsUnavailableSenderRollbackIntegrationTest { @SuppressWarnings("unchecked") void setUp() { ValueOperations 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.hasKey(anyString())).thenReturn(false); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationAtomicConsumptionTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationAtomicConsumptionTest.java index 5dd6e4b..d8a7f8b 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationAtomicConsumptionTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationAtomicConsumptionTest.java @@ -28,6 +28,7 @@ class SmsVerificationAtomicConsumptionTest { @Mock ValueOperations values; @Mock AuditService audit; @Mock SmsCodeSender sender; + @Mock SmsFailedAttemptRecorder failedAttemptRecorder; @InjectMocks SmsVerificationService service; @BeforeEach diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationSecurityTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationSecurityTest.java index fafbff4..a5ebe32 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationSecurityTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationSecurityTest.java @@ -73,6 +73,9 @@ class SmsVerificationSecurityTest { @Mock private SmsCodeSender smsCodeSender; + @Mock + private SmsFailedAttemptRecorder failedAttemptRecorder; + @InjectMocks private SmsVerificationService service; @@ -112,6 +115,8 @@ class SmsVerificationSecurityTest { private void stubSendAllowed() { 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))) .thenAnswer(invocation -> invocation.getArgument(0)); } @@ -218,15 +223,12 @@ class SmsVerificationSecurityTest { when(redisTemplate.hasKey(anyString())).thenReturn(false); when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE)) .thenReturn(Stream.of(stored)); - ArgumentCaptor captor = ArgumentCaptor.forClass(SmsVerificationCode.class); - assertThatThrownBy(() -> service.verifyCode(SENSITIVE_PHONE, "999999", PURPOSE)) .isInstanceOf(BusinessException.class) .satisfies(e -> assertThat(((BusinessException) e).getErrorCodeInt()) .isEqualTo(ErrorCode.SMS_CODE_INVALID.getCode())); - verify(codeRepository).save(captor.capture()); - assertThat(captor.getValue().getAttempts()).isEqualTo(1); + verify(failedAttemptRecorder).record(eq(stored.getId()), any(LocalDateTime.class)); assertThat(stored.getUsed()).isFalse(); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java index d0ef351..3a61314 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java @@ -39,6 +39,9 @@ class SmsVerificationServiceTest { @Mock private SmsCodeSender smsCodeSender; + @Mock + private SmsFailedAttemptRecorder failedAttemptRecorder; + @InjectMocks private SmsVerificationService smsVerificationService; @@ -50,11 +53,13 @@ class SmsVerificationServiceTest { @Test void sendCode_shouldSucceed() { 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); assertDoesNotThrow(() -> smsVerificationService.sendCode("13800000001", "login", null, null, null)); 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 diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/controller/AuthIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/controller/AuthIntegrationTest.java index a1aa0f4..22f7e1f 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/controller/AuthIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/controller/AuthIntegrationTest.java @@ -86,10 +86,12 @@ class AuthIntegrationTest { void setUp() { // Stub Redis 操作:禁用频率限制和锁定 when(redisTemplate.hasKey(anyString())).thenReturn(false); - // 使用 CALLS_REAL_METHODS 避免未 stub 方法抛出 NPE + // 测试中的 Redis 模拟明确授予原子发送名额。 org.springframework.data.redis.core.ValueOperations ops = - mock(org.springframework.data.redis.core.ValueOperations.class, - org.mockito.Answers.CALLS_REAL_METHODS); + mock(org.springframework.data.redis.core.ValueOperations.class); + 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); codeRepository.deleteAll(); authTokenRepository.deleteAll(); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/controller/AuthValidationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/controller/AuthValidationTest.java index c9163f5..c49f9d0 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/controller/AuthValidationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/controller/AuthValidationTest.java @@ -39,8 +39,10 @@ class AuthValidationTest { void setUp() { when(redisTemplate.hasKey(anyString())).thenReturn(false); org.springframework.data.redis.core.ValueOperations ops = - org.mockito.Mockito.mock(org.springframework.data.redis.core.ValueOperations.class, - org.mockito.Answers.CALLS_REAL_METHODS); + org.mockito.Mockito.mock(org.springframework.data.redis.core.ValueOperations.class); + 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); }