88 encryptionMock ,
99 encryptionMockFns ,
1010 loggingSessionMock ,
11+ requestUtilsMockFns ,
1112 workflowsUtilsMock ,
1213} from '@sim/testing'
1314import type { NextResponse } from 'next/server'
@@ -208,6 +209,18 @@ describe('Chat API Utils', () => {
208209
209210 const result = await validateChatAuth ( 'request-id' , deployment , mockRequest , parsedBody )
210211
212+ expect ( mockCheckRateLimitDirect ) . toHaveBeenNthCalledWith (
213+ 1 ,
214+ 'chat-password:ip:chat-id:127.0.0.1' ,
215+ expect . objectContaining ( { maxTokens : 10 } ) ,
216+ { failClosed : true }
217+ )
218+ expect ( mockCheckRateLimitDirect ) . toHaveBeenNthCalledWith (
219+ 2 ,
220+ 'chat-password:resource:chat-id' ,
221+ expect . objectContaining ( { maxTokens : 100 } ) ,
222+ { failClosed : true }
223+ )
211224 expect ( decryptSecret ) . toHaveBeenCalledWith ( 'encrypted-password' )
212225 expect ( result . authorized ) . toBe ( true )
213226 } )
@@ -236,7 +249,7 @@ describe('Chat API Utils', () => {
236249 expect ( result . error ) . toBe ( 'Invalid password' )
237250 } )
238251
239- it ( 'should return 429 when the password attempt rate limit is exceeded' , async ( ) => {
252+ it ( 'should return 429 when the password IP rate limit is exceeded' , async ( ) => {
240253 mockCheckRateLimitDirect . mockResolvedValueOnce ( { allowed : false , retryAfterMs : 60_000 } )
241254
242255 const deployment = {
@@ -260,6 +273,67 @@ describe('Chat API Utils', () => {
260273 expect ( result . status ) . toBe ( 429 )
261274 expect ( result . retryAfterMs ) . toBe ( 60_000 )
262275 expect ( decryptSecret ) . not . toHaveBeenCalled ( )
276+ expect ( mockCheckRateLimitDirect ) . toHaveBeenCalledWith (
277+ 'chat-password:ip:chat-id:127.0.0.1' ,
278+ expect . objectContaining ( { maxTokens : 10 } ) ,
279+ { failClosed : true }
280+ )
281+ } )
282+
283+ it ( 'should return 429 when the password resource rate limit is exceeded' , async ( ) => {
284+ mockCheckRateLimitDirect
285+ . mockResolvedValueOnce ( { allowed : true } )
286+ . mockResolvedValueOnce ( { allowed : false , retryAfterMs : 30_000 } )
287+
288+ const deployment = {
289+ id : 'chat-id' ,
290+ authType : 'password' ,
291+ password : 'encrypted-password' ,
292+ }
293+ const mockRequest = {
294+ method : 'POST' ,
295+ cookies : { get : vi . fn ( ) . mockReturnValue ( null ) } ,
296+ } as any
297+
298+ const result = await validateChatAuth ( 'request-id' , deployment , mockRequest , {
299+ password : 'any-guess' ,
300+ } )
301+
302+ expect ( result ) . toEqual (
303+ expect . objectContaining ( { authorized : false , status : 429 , retryAfterMs : 30_000 } )
304+ )
305+ expect ( mockCheckRateLimitDirect ) . toHaveBeenNthCalledWith (
306+ 2 ,
307+ 'chat-password:resource:chat-id' ,
308+ expect . objectContaining ( { maxTokens : 100 } ) ,
309+ { failClosed : true }
310+ )
311+ expect ( decryptSecret ) . not . toHaveBeenCalled ( )
312+ } )
313+
314+ it ( 'should retain the password resource limit when the client IP cannot be resolved' , async ( ) => {
315+ requestUtilsMockFns . mockGetClientIp . mockReturnValueOnce ( null )
316+ const deployment = {
317+ id : 'chat-id' ,
318+ authType : 'password' ,
319+ password : 'encrypted-password' ,
320+ }
321+ const mockRequest = {
322+ method : 'POST' ,
323+ cookies : { get : vi . fn ( ) . mockReturnValue ( null ) } ,
324+ } as any
325+
326+ const result = await validateChatAuth ( 'request-id' , deployment , mockRequest , {
327+ password : 'correct-password' ,
328+ } )
329+
330+ expect ( result . authorized ) . toBe ( true )
331+ expect ( mockCheckRateLimitDirect ) . toHaveBeenCalledTimes ( 1 )
332+ expect ( mockCheckRateLimitDirect ) . toHaveBeenCalledWith (
333+ 'chat-password:resource:chat-id' ,
334+ expect . objectContaining ( { maxTokens : 100 } ) ,
335+ { failClosed : true }
336+ )
263337 } )
264338
265339 it ( 'should request email auth for email-protected chats' , async ( ) => {
0 commit comments