11import { ModelStatic } from "sequelize/types"
22import { Sequelize } from "sequelize-typescript"
33
4+ import { config } from "@root/config/config"
45import { Otp , User , Whitelist } from "@root/database/models"
6+ import { BadRequestError } from "@root/errors/BadRequestError"
57import SmsClient from "@services/identity/SmsClient"
68import TotpGenerator from "@services/identity/TotpGenerator"
79import MailClient from "@services/utilServices/MailClient"
@@ -39,8 +41,30 @@ const MockWhitelist = {
3941 findAll : jest . fn ( ) ,
4042}
4143
44+ let dbAttempts = 0
45+ let dbAttemptsByIp : Record < string , number > = { }
46+
47+ const futureDate = new Date ( Date . now ( ) + 60 * 60 * 1000 )
48+
4249const MockOtp = {
43- findOne : jest . fn ( ) ,
50+ findOne : jest . fn ( ) . mockImplementation ( ( ) =>
51+ Promise . resolve ( {
52+ id : 1 ,
53+ email : mockEmail ,
54+ hashedOtp : "hashed" ,
55+ attempts : dbAttempts ,
56+ attemptsByIp : { ...dbAttemptsByIp } ,
57+ expiresAt : futureDate ,
58+ destroy : jest . fn ( ) ,
59+ } )
60+ ) ,
61+ update : jest . fn ( ) . mockImplementation ( ( values : any , options : any ) => {
62+ if ( options ?. where ?. attempts !== dbAttempts ) return Promise . resolve ( [ 0 ] )
63+
64+ dbAttempts = values . attempts
65+ dbAttemptsByIp = { ...( values . attemptsByIp || { } ) }
66+ return Promise . resolve ( [ 1 ] )
67+ } ) ,
4468}
4569
4670const UsersService = new _UsersService ( {
5781const mockGithubId = "sudowoodo"
5882
5983describe ( "User Service" , ( ) => {
60- afterEach ( ( ) => jest . clearAllMocks ( ) )
84+ afterEach ( ( ) => {
85+ jest . clearAllMocks ( )
86+ dbAttempts = 0
87+ dbAttemptsByIp = { }
88+ } )
6189
6290 it ( "should return the result of calling the findOne method by email on the db model" , ( ) => {
6391 // Arrange
@@ -74,6 +102,56 @@ describe("User Service", () => {
74102 } )
75103 } )
76104
105+ describe ( "verifyOtp per-IP behavior" , ( ) => {
106+ const maxAttempts = config . get ( "auth.maxNumOtpAttempts" )
107+ const wrongOtp = "000000"
108+
109+ it ( "increments attempts for provided IP and returns invalid until max" , async ( ) => {
110+ ( MockOtpService . verifyOtp as jest . Mock ) . mockResolvedValue ( false )
111+ const ip = "1.1.1.1"
112+
113+ for ( let i = 1 ; i <= maxAttempts ; i += 1 ) {
114+ // eslint-disable-next-line no-await-in-loop
115+ const result = await UsersService . verifyEmailOtp ( mockEmail , wrongOtp , ip )
116+ expect ( result . isErr ( ) ) . toBe ( true )
117+ const err = result . _unsafeUnwrapErr ( )
118+ expect ( err ) . toBeInstanceOf ( BadRequestError )
119+ if ( i < maxAttempts ) {
120+ expect ( ( err as BadRequestError ) . message ) . toBe ( "OTP is not valid" )
121+ } else {
122+ expect ( ( err as BadRequestError ) . message ) . toBe (
123+ "Max number of attempts reached"
124+ )
125+ }
126+ }
127+
128+ expect ( MockOtp . update ) . toHaveBeenCalled ( )
129+ expect ( dbAttemptsByIp [ ip ] ) . toBe ( maxAttempts - 1 ) // last call rejected before increment
130+ } )
131+
132+ it ( "uses 'unknown' bucket when clientIp is missing" , async ( ) => {
133+ ( MockOtpService . verifyOtp as jest . Mock ) . mockResolvedValue ( false )
134+
135+ const result = await UsersService . verifyEmailOtp ( mockEmail , wrongOtp )
136+ expect ( result . isErr ( ) ) . toBe ( true )
137+ result . _unsafeUnwrapErr ( ) // ensure unwrap doesn't throw
138+ expect ( dbAttemptsByIp . unknown ) . toBe ( 1 )
139+ } )
140+
141+ it ( "tracks per-IP separately" , async ( ) => {
142+ ( MockOtpService . verifyOtp as jest . Mock ) . mockResolvedValue ( false )
143+ const ipA = "1.1.1.1"
144+ const ipB = "2.2.2.2"
145+
146+ await UsersService . verifyEmailOtp ( mockEmail , wrongOtp , ipA )
147+ await UsersService . verifyEmailOtp ( mockEmail , wrongOtp , ipA )
148+ await UsersService . verifyEmailOtp ( mockEmail , wrongOtp , ipB )
149+
150+ expect ( dbAttemptsByIp [ ipA ] ) . toBe ( 2 )
151+ expect ( dbAttemptsByIp [ ipB ] ) . toBe ( 1 )
152+ } )
153+ } )
154+
77155 it ( "should return the result of calling the findOne method by githubId on the db model" , ( ) => {
78156 // Arrange
79157 const expected = "user1"
0 commit comments