@@ -112,6 +112,16 @@ vi.mock('@/lib/core/storage', () => ({
112112 getStorageMethod : mockGetStorageMethod ,
113113} ) )
114114
115+ const { mockCheckRateLimitDirect } = vi . hoisted ( ( ) => ( {
116+ mockCheckRateLimitDirect : vi . fn ( ) ,
117+ } ) )
118+
119+ vi . mock ( '@/lib/core/rate-limiter' , ( ) => ( {
120+ RateLimiter : class {
121+ checkRateLimitDirect = mockCheckRateLimitDirect
122+ } ,
123+ } ) )
124+
115125vi . mock ( '@/lib/messaging/email/mailer' , ( ) => ( {
116126 sendEmail : mockSendEmail ,
117127} ) )
@@ -234,6 +244,13 @@ describe('Chat OTP API Route', () => {
234244 } ) )
235245
236246 requestUtilsMockFns . mockGenerateRequestId . mockReturnValue ( 'req-123' )
247+ requestUtilsMockFns . mockGetClientIp . mockReturnValue ( '1.2.3.4' )
248+
249+ mockCheckRateLimitDirect . mockResolvedValue ( {
250+ allowed : true ,
251+ remaining : 10 ,
252+ resetAt : new Date ( Date . now ( ) + 60_000 ) ,
253+ } )
237254
238255 mockZodParse . mockImplementation ( ( data : unknown ) => data )
239256
@@ -283,6 +300,134 @@ describe('Chat OTP API Route', () => {
283300 } )
284301 } )
285302
303+ describe ( 'POST - Rate limiting' , ( ) => {
304+ const buildDeploymentSelect = ( ) =>
305+ mockDbSelect . mockImplementationOnce ( ( ) => ( {
306+ from : vi . fn ( ) . mockReturnValue ( {
307+ where : vi . fn ( ) . mockReturnValue ( {
308+ limit : vi . fn ( ) . mockResolvedValue ( [
309+ {
310+ id : mockChatId ,
311+ authType : 'email' ,
312+ allowedEmails : [ mockEmail ] ,
313+ title : 'Test Chat' ,
314+ } ,
315+ ] ) ,
316+ } ) ,
317+ } ) ,
318+ } ) )
319+
320+ it ( 'returns 429 with Retry-After when IP rate limit is exceeded' , async ( ) => {
321+ mockCheckRateLimitDirect . mockResolvedValueOnce ( {
322+ allowed : false ,
323+ remaining : 0 ,
324+ resetAt : new Date ( Date . now ( ) + 900_000 ) ,
325+ retryAfterMs : 900_000 ,
326+ } )
327+
328+ const headerSet = vi . fn ( )
329+ mockCreateErrorResponse . mockImplementationOnce ( ( message : string , status : number ) => ( {
330+ json : ( ) => Promise . resolve ( { error : message } ) ,
331+ status,
332+ headers : { set : headerSet } ,
333+ } ) )
334+
335+ const request = new NextRequest ( 'http://localhost:3000/api/chat/test/otp' , {
336+ method : 'POST' ,
337+ body : JSON . stringify ( { email : mockEmail } ) ,
338+ } )
339+
340+ const response = await POST ( request , {
341+ params : Promise . resolve ( { identifier : mockIdentifier } ) ,
342+ } )
343+
344+ expect ( response . status ) . toBe ( 429 )
345+ expect ( headerSet ) . toHaveBeenCalledWith ( 'Retry-After' , '900' )
346+ expect ( mockSendEmail ) . not . toHaveBeenCalled ( )
347+ expect ( mockDbSelect ) . not . toHaveBeenCalled ( )
348+ } )
349+
350+ it ( 'returns 429 with Retry-After when email rate limit is exceeded' , async ( ) => {
351+ mockCheckRateLimitDirect
352+ . mockResolvedValueOnce ( {
353+ allowed : true ,
354+ remaining : 9 ,
355+ resetAt : new Date ( Date . now ( ) + 60_000 ) ,
356+ } )
357+ . mockResolvedValueOnce ( {
358+ allowed : false ,
359+ remaining : 0 ,
360+ resetAt : new Date ( Date . now ( ) + 900_000 ) ,
361+ retryAfterMs : 900_000 ,
362+ } )
363+
364+ const headerSet = vi . fn ( )
365+ mockCreateErrorResponse . mockImplementationOnce ( ( message : string , status : number ) => ( {
366+ json : ( ) => Promise . resolve ( { error : message } ) ,
367+ status,
368+ headers : { set : headerSet } ,
369+ } ) )
370+
371+ buildDeploymentSelect ( )
372+
373+ const request = new NextRequest ( 'http://localhost:3000/api/chat/test/otp' , {
374+ method : 'POST' ,
375+ body : JSON . stringify ( { email : mockEmail } ) ,
376+ } )
377+
378+ const response = await POST ( request , {
379+ params : Promise . resolve ( { identifier : mockIdentifier } ) ,
380+ } )
381+
382+ expect ( response . status ) . toBe ( 429 )
383+ expect ( headerSet ) . toHaveBeenCalledWith ( 'Retry-After' , '900' )
384+ expect ( mockSendEmail ) . not . toHaveBeenCalled ( )
385+ } )
386+
387+ it ( 'falls back to refill interval when retryAfterMs is missing' , async ( ) => {
388+ mockCheckRateLimitDirect . mockResolvedValueOnce ( {
389+ allowed : false ,
390+ remaining : 0 ,
391+ resetAt : new Date ( Date . now ( ) + 900_000 ) ,
392+ } )
393+
394+ const headerSet = vi . fn ( )
395+ mockCreateErrorResponse . mockImplementationOnce ( ( message : string , status : number ) => ( {
396+ json : ( ) => Promise . resolve ( { error : message } ) ,
397+ status,
398+ headers : { set : headerSet } ,
399+ } ) )
400+
401+ const request = new NextRequest ( 'http://localhost:3000/api/chat/test/otp' , {
402+ method : 'POST' ,
403+ body : JSON . stringify ( { email : mockEmail } ) ,
404+ } )
405+
406+ await POST ( request , { params : Promise . resolve ( { identifier : mockIdentifier } ) } )
407+
408+ expect ( headerSet ) . toHaveBeenCalledWith ( 'Retry-After' , '900' )
409+ } )
410+
411+ it ( 'skips IP rate limit when client IP is unknown' , async ( ) => {
412+ requestUtilsMockFns . mockGetClientIp . mockReturnValueOnce ( 'unknown' )
413+ buildDeploymentSelect ( )
414+
415+ const request = new NextRequest ( 'http://localhost:3000/api/chat/test/otp' , {
416+ method : 'POST' ,
417+ body : JSON . stringify ( { email : mockEmail } ) ,
418+ } )
419+
420+ await POST ( request , { params : Promise . resolve ( { identifier : mockIdentifier } ) } )
421+
422+ // Only the email-scoped check should run, not the IP-scoped one
423+ expect ( mockCheckRateLimitDirect ) . toHaveBeenCalledTimes ( 1 )
424+ expect ( mockCheckRateLimitDirect ) . toHaveBeenCalledWith (
425+ expect . stringContaining ( 'chat-otp:email:' ) ,
426+ expect . any ( Object )
427+ )
428+ } )
429+ } )
430+
286431 describe ( 'POST - Store OTP (Database path)' , ( ) => {
287432 beforeEach ( ( ) => {
288433 mockGetStorageMethod . mockReturnValue ( 'database' )
0 commit comments