@@ -14,6 +14,7 @@ import {
1414 workflowsApiUtilsMock ,
1515 workflowsApiUtilsMockFns ,
1616} from '@sim/testing'
17+ import { NextResponse } from 'next/server'
1718import { beforeEach , describe , expect , it , vi } from 'vitest'
1819
1920/**
@@ -65,10 +66,18 @@ const createMockStream = () => {
6566 } )
6667}
6768
68- const { mockValidateChatAuth, mockSetChatAuthCookie, mockProcessChatFiles } = vi . hoisted ( ( ) => ( {
69+ const {
70+ mockValidateChatAuth,
71+ mockSetChatAuthCookie,
72+ mockProcessChatFiles,
73+ mockEnforceIpRateLimit,
74+ mockEnforceResourceRateLimit,
75+ } = vi . hoisted ( ( ) => ( {
6976 mockValidateChatAuth : vi . fn ( ) . mockResolvedValue ( { authorized : true } ) ,
7077 mockSetChatAuthCookie : vi . fn ( ) ,
7178 mockProcessChatFiles : vi . fn ( ) ,
79+ mockEnforceIpRateLimit : vi . fn ( ) ,
80+ mockEnforceResourceRateLimit : vi . fn ( ) ,
7281} ) )
7382
7483const mockCreateErrorResponse = workflowsApiUtilsMockFns . mockCreateErrorResponse
@@ -117,6 +126,12 @@ vi.mock('@/lib/core/utils/sse', () => ({
117126
118127vi . mock ( '@/lib/core/security/encryption' , ( ) => encryptionMock )
119128
129+ vi . mock ( '@/lib/core/rate-limiter' , ( ) => ( {
130+ enforceIpRateLimitWithIndependentBackstop : mockEnforceIpRateLimit ,
131+ enforceResourceRateLimit : mockEnforceResourceRateLimit ,
132+ } ) )
133+
134+ import { RATE_LIMITS } from '@/lib/core/rate-limiter/types'
120135import { preprocessExecution } from '@/lib/execution/preprocessing'
121136import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow'
122137import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
@@ -182,6 +197,8 @@ describe('Chat Identifier API Route', () => {
182197 } )
183198
184199 mockValidateChatAuth . mockResolvedValue ( { authorized : true } )
200+ mockEnforceIpRateLimit . mockResolvedValue ( null )
201+ mockEnforceResourceRateLimit . mockResolvedValue ( null )
185202 mockProcessChatFiles . mockResolvedValue ( [ ] )
186203 mockCreateErrorResponse . mockImplementation ( ( message : string , status : number , code ?: string ) => {
187204 return new Response (
@@ -335,6 +352,107 @@ describe('Chat Identifier API Route', () => {
335352 expect ( mockSetChatAuthCookie ) . toHaveBeenCalledWith ( expect . anything ( ) , passwordDeployment )
336353 } )
337354
355+ describe ( 'execution rate limit' , ( ) => {
356+ it . each ( [
357+ [ 'per-IP' , mockEnforceIpRateLimit ] ,
358+ [ 'per-deployment' , mockEnforceResourceRateLimit ] ,
359+ ] ) ( "refuses on the %s bucket before the owner's budget is reserved" , async ( _ , bucket ) => {
360+ bucket . mockResolvedValue (
361+ NextResponse . json ( { error : 'Rate limit exceeded' } , { status : 429 } )
362+ )
363+ const req = createMockNextRequest ( 'POST' , { input : 'drain the wallet' } )
364+
365+ const response = await POST ( req , { params : Promise . resolve ( { identifier : 'test-chat' } ) } )
366+
367+ expect ( response . status ) . toBe ( 429 )
368+ expect ( preprocessExecution ) . not . toHaveBeenCalled ( )
369+ expect ( createStreamingResponse ) . not . toHaveBeenCalled ( )
370+ expect ( mockProcessChatFiles ) . not . toHaveBeenCalled ( )
371+ } )
372+
373+ it ( 'debits buckets keyed on the deployment, not the workflow' , async ( ) => {
374+ const req = createMockNextRequest ( 'POST' , { input : 'hello' } )
375+
376+ await POST ( req , { params : Promise . resolve ( { identifier : 'test-chat' } ) } )
377+
378+ expect ( mockEnforceIpRateLimit ) . toHaveBeenCalledWith (
379+ 'chat-execute' ,
380+ req ,
381+ expect . objectContaining ( { refillIntervalMs : 60_000 } ) ,
382+ 'chat-id'
383+ )
384+ expect ( mockEnforceResourceRateLimit ) . toHaveBeenCalledWith (
385+ 'chat-execute' ,
386+ 'chat-id' ,
387+ expect . objectContaining ( { refillIntervalMs : 60_000 } )
388+ )
389+ } )
390+
391+ it ( 'leaves the deployment bucket untouched when the IP bucket refuses' , async ( ) => {
392+ mockEnforceIpRateLimit . mockResolvedValue ( NextResponse . json ( { } , { status : 429 } ) )
393+ const req = createMockNextRequest ( 'POST' , { input : 'flood' } )
394+
395+ await POST ( req , { params : Promise . resolve ( { identifier : 'test-chat' } ) } )
396+
397+ expect ( mockEnforceResourceRateLimit ) . not . toHaveBeenCalled ( )
398+ } )
399+
400+ /**
401+ * The invariant the ceiling exists to hold. A chat execution debits the
402+ * workspace `sync` counter the owner's API, webhook and scheduled runs
403+ * share, so a ceiling at or above a plan's own rate never refuses before
404+ * that shared counter is drained — the availability half of the attack.
405+ * Asserted against every plan, including free, and on burst as well as
406+ * sustained rate, since either one reaching the plan bucket first is the
407+ * same hole.
408+ */
409+ it . each ( Object . keys ( RATE_LIMITS ) ) (
410+ 'stays under the %s plan sync budget it debits' ,
411+ async ( plan ) => {
412+ const req = createMockNextRequest ( 'POST' , { input : 'hello' } )
413+
414+ await POST ( req , { params : Promise . resolve ( { identifier : 'test-chat' } ) } )
415+
416+ const planBucket = RATE_LIMITS [ plan as keyof typeof RATE_LIMITS ] . sync
417+ const [ , , config ] = mockEnforceResourceRateLimit . mock . calls [ 0 ]
418+ expect ( config . refillRate ) . toBeLessThan ( planBucket . refillRate )
419+ expect ( config . maxTokens ) . toBeLessThan ( planBucket . maxTokens )
420+ }
421+ )
422+
423+ /** One host must not be able to take the whole deployment's allowance. */
424+ it ( 'holds the per-IP bucket under the per-deployment one' , async ( ) => {
425+ const req = createMockNextRequest ( 'POST' , { input : 'hello' } )
426+
427+ await POST ( req , { params : Promise . resolve ( { identifier : 'test-chat' } ) } )
428+
429+ const [ , , ipConfig ] = mockEnforceIpRateLimit . mock . calls [ 0 ]
430+ const [ , , deploymentConfig ] = mockEnforceResourceRateLimit . mock . calls [ 0 ]
431+ expect ( ipConfig . refillRate ) . toBeLessThan ( deploymentConfig . refillRate )
432+ } )
433+
434+ it ( 'leaves the gate-configuration fetch unmetered' , async ( ) => {
435+ const passwordDeployment = {
436+ ...mockChatResult [ 0 ] ,
437+ authType : 'password' ,
438+ password : 'encrypted-password' ,
439+ }
440+ dbChainMockFns . select . mockImplementation ( ( ) => ( {
441+ from : vi . fn ( ) . mockReturnValue ( {
442+ where : vi . fn ( ) . mockReturnValue ( {
443+ limit : vi . fn ( ) . mockReturnValue ( [ passwordDeployment ] ) ,
444+ } ) ,
445+ } ) ,
446+ } ) )
447+ const req = createMockNextRequest ( 'POST' , { password : 'test-password' } )
448+
449+ await POST ( req , { params : Promise . resolve ( { identifier : 'password-protected-chat' } ) } )
450+
451+ expect ( mockEnforceIpRateLimit ) . not . toHaveBeenCalled ( )
452+ expect ( mockEnforceResourceRateLimit ) . not . toHaveBeenCalled ( )
453+ } )
454+ } )
455+
338456 it ( 'should return 400 for requests without input' , async ( ) => {
339457 const req = createMockNextRequest ( 'POST' , { } )
340458 const params = Promise . resolve ( { identifier : 'test-chat' } )
0 commit comments