@@ -9,6 +9,8 @@ import { BlockRecord, Rule, TOO_MANY_ATTEMPTS } from './models';
99import { calculateRetryAfter , getKey } from './util' ;
1010import { StatsD } from 'hot-shots' ;
1111
12+ const MOCK_NOW = 1_700_000_000_000 ;
13+
1214describe ( 'rate-limit' , ( ) => {
1315 let redis : Redis ;
1416 let mockIncrement : jest . Mock ;
@@ -857,6 +859,126 @@ describe('rate-limit', () => {
857859 ) ;
858860 } ) ;
859861
862+ it ( 'reports currentAttempts as the tallied count when no block is triggered' , async ( ) => {
863+ mockIncr . mockResolvedValue ( 3 ) ; // under maxAttempts of 5
864+
865+ rateLimit = new RateLimit (
866+ { rules : parseConfigRules ( [ 'test:ip:5:1 minute:5 minutes:block' ] ) } ,
867+ redis ,
868+ statsd ,
869+ { write : mockWrite }
870+ ) ;
871+
872+ await rateLimit . check ( 'test' , { ip : '1.2.3.4' } ) ;
873+
874+ expect ( mockWrite ) . toHaveBeenCalledWith (
875+ expect . objectContaining ( {
876+ wasBlocked : false ,
877+ currentAttempts : 3 ,
878+ } )
879+ ) ;
880+ } ) ;
881+
882+ it ( 'reports currentAttempts as the tallied count when a new block is triggered' , async ( ) => {
883+ mockIncr . mockResolvedValue ( 6 ) ; // exceeds maxAttempts of 5
884+ redis . set = jest . fn ( ) . mockResolvedValue ( 'OK' ) ;
885+
886+ rateLimit = new RateLimit (
887+ { rules : parseConfigRules ( [ 'test:ip:5:1 minute:5 minutes:block' ] ) } ,
888+ redis ,
889+ statsd ,
890+ { write : mockWrite }
891+ ) ;
892+
893+ await rateLimit . check ( 'test' , { ip : '1.2.3.4' } ) ;
894+
895+ expect ( mockWrite ) . toHaveBeenCalledWith (
896+ expect . objectContaining ( {
897+ wasBlocked : true ,
898+ currentAttempts : 6 ,
899+ } )
900+ ) ;
901+ } ) ;
902+
903+ it ( 'reports currentAttempts as maxAttempts when short-circuiting on a pre-existing block' , async ( ) => {
904+ // A block already exists in Redis, so check() never tallies attempts.
905+ // The stored record's own attempt count (99) must NOT be used; the
906+ // reported value falls back to the rule's maxAttempts (5).
907+ const existingBlock = {
908+ action : 'test' ,
909+ usedDefaultRule : false ,
910+ blockingOn : 'ip' ,
911+ blockedValue : '1.2.3.4' ,
912+ startTime : MOCK_NOW ,
913+ duration : 300 ,
914+ attempts : 99 ,
915+ reason : 'too-many-attempts' ,
916+ policy : 'block' ,
917+ } ;
918+ // Bans are checked first via redis.get; return null for those, and the
919+ // block record for the block key.
920+ mockGet . mockImplementation ( ( key : string ) =>
921+ Promise . resolve (
922+ key . includes ( ':block:' ) ? JSON . stringify ( existingBlock ) : null
923+ )
924+ ) ;
925+
926+ rateLimit = new RateLimit (
927+ { rules : parseConfigRules ( [ 'test:ip:5:1 minute:5 minutes:block' ] ) } ,
928+ redis ,
929+ statsd ,
930+ { write : mockWrite }
931+ ) ;
932+
933+ await rateLimit . check ( 'test' , { ip : '1.2.3.4' } ) ;
934+
935+ expect ( mockIncr ) . not . toHaveBeenCalled ( ) ;
936+ expect ( mockWrite ) . toHaveBeenCalledWith (
937+ expect . objectContaining ( {
938+ wasBlocked : true ,
939+ currentAttempts : 5 ,
940+ } )
941+ ) ;
942+ } ) ;
943+
944+ it ( 'reports currentAttempts as undefined when a ban short-circuits the check' , async ( ) => {
945+ // A ban has no associated rule (rules is empty), so the number of
946+ // attempts cannot be determined.
947+ const existingBan = {
948+ action : 'test' ,
949+ usedDefaultRule : false ,
950+ blockingOn : 'ip' ,
951+ blockedValue : '1.2.3.4' ,
952+ startTime : MOCK_NOW ,
953+ duration : 300 ,
954+ attempts : 42 ,
955+ reason : 'too-many-attempts' ,
956+ policy : 'ban' ,
957+ } ;
958+ mockGet . mockImplementation ( ( key : string ) =>
959+ Promise . resolve (
960+ key . includes ( ':ban:' ) ? JSON . stringify ( existingBan ) : null
961+ )
962+ ) ;
963+
964+ rateLimit = new RateLimit (
965+ { rules : parseConfigRules ( [ 'test:ip:5:1 minute:5 minutes:ban' ] ) } ,
966+ redis ,
967+ statsd ,
968+ { write : mockWrite }
969+ ) ;
970+
971+ await rateLimit . check ( 'test' , { ip : '1.2.3.4' } ) ;
972+
973+ expect ( mockIncr ) . not . toHaveBeenCalled ( ) ;
974+ expect ( mockWrite ) . toHaveBeenCalledWith (
975+ expect . objectContaining ( {
976+ wasBlocked : true ,
977+ currentAttempts : undefined ,
978+ } )
979+ ) ;
980+ } ) ;
981+
860982 it ( 'calls bqWriter.write on skip() with wasSkipped true' , ( ) => {
861983 rateLimit = new RateLimit (
862984 { rules : { } , ignoreIPs : [ '127.0.0.1' ] } ,
0 commit comments