33import ai .nubase .common .config .AuthConfig ;
44import org .junit .jupiter .api .DisplayName ;
55import org .junit .jupiter .api .Test ;
6+ import org .mockito .ArgumentCaptor ;
7+ import org .mockito .ArgumentMatchers ;
8+ import org .springframework .data .redis .core .StringRedisTemplate ;
9+ import org .springframework .data .redis .core .ValueOperations ;
10+ import org .springframework .data .redis .core .script .RedisScript ;
11+ import org .springframework .test .util .ReflectionTestUtils ;
612
13+ import java .time .Duration ;
14+ import java .util .List ;
15+
16+ import static org .assertj .core .api .Assertions .assertThat ;
717import static org .assertj .core .api .Assertions .assertThatCode ;
818import static org .assertj .core .api .Assertions .assertThatThrownBy ;
19+ import static org .mockito .ArgumentMatchers .any ;
20+ import static org .mockito .ArgumentMatchers .anyList ;
21+ import static org .mockito .ArgumentMatchers .eq ;
22+ import static org .mockito .Mockito .mock ;
23+ import static org .mockito .Mockito .never ;
24+ import static org .mockito .Mockito .times ;
25+ import static org .mockito .Mockito .verify ;
26+ import static org .mockito .Mockito .when ;
927
1028/**
1129 * Pure unit tests for {@link RateLimiterService} (sliding-window cap + failed-login lockout).
@@ -23,6 +41,13 @@ private RateLimiterService limiter(int maxReq, int window, int maxFail, int lock
2341 return new RateLimiterService (new EffectiveAuthConfig (cfg ));
2442 }
2543
44+ private RateLimiterService redisLimiter (int maxReq , int window , int maxFail , int lockout ) {
45+ RateLimiterService rl = limiter (maxReq , window , maxFail , lockout );
46+ StringRedisTemplate redisTemplate = mock (StringRedisTemplate .class );
47+ ReflectionTestUtils .setField (rl , "redisTemplate" , redisTemplate );
48+ return rl ;
49+ }
50+
2651 @ Test
2752 @ DisplayName ("checkRate allows up to the cap, then throws" )
2853 void rateCap () {
@@ -81,4 +106,51 @@ void disabled() {
81106 }
82107 assertThatCode (() -> rl .assertNotLockedOut ("a@x.com" )).doesNotThrowAnyException ();
83108 }
109+
110+ @ Test
111+ @ DisplayName ("Redis checkRate uses atomic INCR+PEXPIRE script and enforces cap" )
112+ void redisRateCapUsesAtomicScript () {
113+ RateLimiterService rl = redisLimiter (2 , 300 , 5 , 900 );
114+ StringRedisTemplate redisTemplate =
115+ (StringRedisTemplate ) ReflectionTestUtils .getField (rl , "redisTemplate" );
116+ when (redisTemplate .execute (ArgumentMatchers .<RedisScript <Long >>any (), anyList (), any ()))
117+ .thenReturn (1L , 2L , 3L );
118+
119+ assertThatCode (() -> rl .checkRate ("otp" , "a@x.com" )).doesNotThrowAnyException ();
120+ assertThatCode (() -> rl .checkRate ("otp" , "a@x.com" )).doesNotThrowAnyException ();
121+ assertThatThrownBy (() -> rl .checkRate ("otp" , "a@x.com" ))
122+ .isInstanceOf (RateLimiterService .RateLimitExceededException .class );
123+
124+ @ SuppressWarnings ("unchecked" )
125+ ArgumentCaptor <List <String >> keysCaptor = ArgumentCaptor .forClass (List .class );
126+ verify (redisTemplate , times (3 ))
127+ .execute (ArgumentMatchers .<RedisScript <Long >>any (), keysCaptor .capture (), eq ("300000" ));
128+ assertThat (keysCaptor .getValue ()).containsExactly ("rl:_:otp:a@x.com" );
129+ verify (redisTemplate , never ()).opsForValue ();
130+ }
131+
132+ @ Test
133+ @ DisplayName ("Redis recordFailure uses atomic INCR+PEXPIRE script before lockout" )
134+ void redisFailureCountUsesAtomicScript () {
135+ RateLimiterService rl = redisLimiter (100 , 300 , 3 , 900 );
136+ StringRedisTemplate redisTemplate =
137+ (StringRedisTemplate ) ReflectionTestUtils .getField (rl , "redisTemplate" );
138+ ValueOperations <String , String > valueOps = mock (ValueOperations .class );
139+ when (redisTemplate .opsForValue ()).thenReturn (valueOps );
140+ when (redisTemplate .execute (ArgumentMatchers .<RedisScript <Long >>any (), anyList (), any ()))
141+ .thenReturn (1L , 2L , 3L );
142+ when (redisTemplate .hasKey ("rl:lock:_:victim@x.com" )).thenReturn (false , true );
143+
144+ String id = "victim@x.com" ;
145+ rl .recordFailure (id );
146+ rl .recordFailure (id );
147+ assertThatCode (() -> rl .assertNotLockedOut (id )).doesNotThrowAnyException ();
148+ rl .recordFailure (id );
149+ assertThatThrownBy (() -> rl .assertNotLockedOut (id ))
150+ .isInstanceOf (RateLimiterService .RateLimitExceededException .class );
151+
152+ verify (redisTemplate , times (3 ))
153+ .execute (ArgumentMatchers .<RedisScript <Long >>any (), anyList (), eq ("900000" ));
154+ verify (valueOps ).set (eq ("rl:lock:_:victim@x.com" ), eq ("1" ), eq (Duration .ofSeconds (900 )));
155+ }
84156}
0 commit comments