@@ -75,6 +75,8 @@ import { searchIssuesV2Tool } from '@/tools/github/search_issues'
7575import { memoryAddTool } from '@/tools/memory/add'
7676import { createInternalToolOperationInput } from '@/tools/operation-input'
7777import { slackListsItemsListTool } from '@/tools/slack_lists/items_list'
78+ import { stripeListSubscriptionsTool } from '@/tools/stripe/list_subscriptions'
79+ import { stripeSearchSubscriptionsTool } from '@/tools/stripe/search_subscriptions'
7880import { getCallerIdentityTool } from '@/tools/sts/get_caller_identity'
7981import { tableBatchInsertRowsTool } from '@/tools/table/batch_insert_rows'
8082import type { InternalToolConfig , ToolResponse } from '@/tools/types'
@@ -183,6 +185,8 @@ vi.mock('@/executor/handlers/workflow/custom-block-tool-runner', () => ({
183185// Mock the tools registry to avoid loading the full 4500+ line registry file.
184186// Only the tools actually exercised in tests are provided.
185187const mockRegistryTools : Record < string , any > = {
188+ stripe_list_subscriptions : stripeListSubscriptionsTool ,
189+ stripe_search_subscriptions : stripeSearchSubscriptionsTool ,
186190 slack_lists_items_list : slackListsItemsListTool ,
187191 github_search_issues_v2 : searchIssuesV2Tool ,
188192 bitbucket_get_pipeline_step_log : bitbucketGetPipelineStepLogTool ,
@@ -2862,6 +2866,163 @@ describe('executeTool Function', () => {
28622866 } )
28632867} )
28642868
2869+ describe ( 'Stripe subscription pagination through tool execution' , ( ) => {
2870+ beforeEach ( ( ) => {
2871+ mockValidateUrlWithDNS . mockResolvedValue ( { isValid : true , resolvedIP : '93.184.216.34' } )
2872+ mockSecureFetchWithPinnedIP . mockReset ( )
2873+ } )
2874+
2875+ it ( 'retains first-page filters and returns one page even when more subscriptions exist' , async ( ) => {
2876+ mockSecureFetchWithPinnedIP . mockResolvedValueOnce (
2877+ toSecureFetchResponse (
2878+ Response . json ( {
2879+ object : 'list' ,
2880+ data : [ { id : 'sub_first' } , { id : 'sub_last' } ] ,
2881+ has_more : true ,
2882+ } )
2883+ )
2884+ )
2885+
2886+ const result = await executeTool ( 'stripe_list_subscriptions' , {
2887+ apiKey : 'sk_test_pagination' ,
2888+ limit : 100 ,
2889+ customer : 'cus_example' ,
2890+ status : 'active' ,
2891+ price : 'price_example' ,
2892+ } )
2893+
2894+ expect ( result ) . toMatchObject ( {
2895+ success : true ,
2896+ output : {
2897+ subscriptions : [ { id : 'sub_first' } , { id : 'sub_last' } ] ,
2898+ metadata : { count : 2 , has_more : true } ,
2899+ } ,
2900+ } )
2901+ expect ( mockSecureFetchWithPinnedIP ) . toHaveBeenCalledExactlyOnceWith (
2902+ 'https://api.stripe.com/v1/subscriptions?limit=100&customer=cus_example&status=active&price=price_example' ,
2903+ '93.184.216.34' ,
2904+ expect . objectContaining ( {
2905+ method : 'GET' ,
2906+ headers : expect . objectContaining ( { authorization : 'Bearer sk_test_pagination' } ) ,
2907+ } )
2908+ )
2909+ } )
2910+
2911+ it . each ( [ 'starting_after' , 'ending_before' ] as const ) (
2912+ 'retrieves the requested %s page without interpreting cursor characters as filters' ,
2913+ async ( direction ) => {
2914+ const cursor = 'sub_cursor&status=canceled'
2915+ mockSecureFetchWithPinnedIP . mockImplementationOnce ( async ( url ) => {
2916+ const request = new URL ( url )
2917+ const valid =
2918+ request . pathname === '/v1/subscriptions' &&
2919+ request . searchParams . get ( direction ) === cursor &&
2920+ request . searchParams . get ( 'status' ) === 'active'
2921+ return toSecureFetchResponse (
2922+ valid
2923+ ? Response . json ( { object : 'list' , data : [ { id : 'sub_next' } ] , has_more : false } )
2924+ : Response . json (
2925+ { error : { message : 'Unexpected pagination request' } } ,
2926+ { status : 400 }
2927+ )
2928+ )
2929+ } )
2930+
2931+ const result = await executeTool ( 'stripe_list_subscriptions' , {
2932+ apiKey : 'sk_test_pagination' ,
2933+ status : 'active' ,
2934+ [ direction ] : cursor ,
2935+ } )
2936+
2937+ expect ( result ) . toMatchObject ( {
2938+ success : true ,
2939+ output : { subscriptions : [ { id : 'sub_next' } ] , metadata : { count : 1 , has_more : false } } ,
2940+ } )
2941+ expect ( mockSecureFetchWithPinnedIP ) . toHaveBeenCalledOnce ( )
2942+ }
2943+ )
2944+
2945+ it ( 'rejects conflicting list cursors before contacting Stripe' , async ( ) => {
2946+ mockSecureFetchWithPinnedIP . mockResolvedValueOnce (
2947+ toSecureFetchResponse ( Response . json ( { object : 'list' , data : [ ] , has_more : false } ) )
2948+ )
2949+
2950+ const result = await executeTool ( 'stripe_list_subscriptions' , {
2951+ apiKey : 'sk_test_pagination' ,
2952+ starting_after : 'sub_last' ,
2953+ ending_before : 'sub_first' ,
2954+ } )
2955+
2956+ expect ( result ) . toMatchObject ( {
2957+ success : false ,
2958+ error : expect . stringContaining ( 'Provide either starting_after or ending_before, not both' ) ,
2959+ } )
2960+ expect ( mockSecureFetchWithPinnedIP ) . not . toHaveBeenCalled ( )
2961+ } )
2962+
2963+ it ( 'round-trips the search continuation token and exposes the terminal page' , async ( ) => {
2964+ const query = "status:'active'"
2965+ const nextPage = 'opaque/search+cursor=='
2966+ mockSecureFetchWithPinnedIP . mockImplementation ( async ( url ) => {
2967+ const request = new URL ( url )
2968+ if (
2969+ request . pathname !== '/v1/subscriptions/search' ||
2970+ request . searchParams . get ( 'query' ) !== query ||
2971+ request . searchParams . get ( 'limit' ) !== '100'
2972+ ) {
2973+ return toSecureFetchResponse (
2974+ Response . json ( { error : { message : 'Search filters changed' } } , { status : 400 } )
2975+ )
2976+ }
2977+ const page = request . searchParams . get ( 'page' )
2978+ if ( page === null ) {
2979+ return toSecureFetchResponse (
2980+ Response . json ( {
2981+ object : 'search_result' ,
2982+ data : [ { id : 'sub_first' } ] ,
2983+ has_more : true ,
2984+ next_page : nextPage ,
2985+ } )
2986+ )
2987+ }
2988+ return toSecureFetchResponse (
2989+ page === nextPage
2990+ ? Response . json ( { object : 'search_result' , data : [ { id : 'sub_last' } ] , has_more : false } )
2991+ : Response . json ( { error : { message : 'Invalid search page' } } , { status : 400 } )
2992+ )
2993+ } )
2994+
2995+ const first = await executeTool ( 'stripe_search_subscriptions' , {
2996+ apiKey : 'sk_test_pagination' ,
2997+ query,
2998+ limit : 100 ,
2999+ } )
3000+ expect ( first ) . toMatchObject ( {
3001+ success : true ,
3002+ output : {
3003+ subscriptions : [ { id : 'sub_first' } ] ,
3004+ metadata : { count : 1 , has_more : true , next_page : nextPage } ,
3005+ } ,
3006+ } )
3007+ expect ( mockSecureFetchWithPinnedIP ) . toHaveBeenCalledOnce ( )
3008+
3009+ const last = await executeTool ( 'stripe_search_subscriptions' , {
3010+ apiKey : 'sk_test_pagination' ,
3011+ query,
3012+ limit : 100 ,
3013+ page : first . output . metadata . next_page ,
3014+ } )
3015+ expect ( last ) . toMatchObject ( {
3016+ success : true ,
3017+ output : {
3018+ subscriptions : [ { id : 'sub_last' } ] ,
3019+ metadata : { count : 1 , has_more : false , next_page : null } ,
3020+ } ,
3021+ } )
3022+ expect ( mockSecureFetchWithPinnedIP ) . toHaveBeenCalledTimes ( 2 )
3023+ } )
3024+ } )
3025+
28653026describe ( 'Internal Route Trust' , ( ) => {
28663027 let cleanupEnvVars : ( ) => void
28673028
0 commit comments