1717 * Every assertion here resolves the built URL with `new URL(...)` — the same
1818 * normalization `fetch` performs — rather than string-matching the template
1919 * output, because string matching is exactly what let this through.
20+ *
21+ * The unit of coverage is a **(tool, parameter) pair**, not a tool. Fuzzing
22+ * every parameter of a tool at once and tolerating a throw is unsound: the
23+ * first guarded parameter throws and silently retires every sibling from the
24+ * suite. HubSpot is where that mattered most — the association tools put the
25+ * four parameters `objectType`, `objectId`, `toObjectType` and `toObjectType`
26+ * into a single path, so guarding the first one would have retired the other
27+ * three. Each case below fuzzes exactly one parameter while holding its
28+ * siblings at a safe value, which is what makes "a newly added unguarded path
29+ * parameter fails CI" actually true. A throw is only accepted as a pass
30+ * *because* the thrown-at parameter is the one under test.
2031 */
2132import { describe , expect , it } from 'vitest'
33+ import { hubspotCreateAssociationTool } from '@/tools/hubspot/create_association'
2234import { hubspotDeleteAssociationTool } from '@/tools/hubspot/delete_association'
2335import { hubspotDeleteCompanyTool } from '@/tools/hubspot/delete_company'
2436import { hubspotDeleteContactTool } from '@/tools/hubspot/delete_contact'
2537import { hubspotDeleteDealTool } from '@/tools/hubspot/delete_deal'
2638import * as hubspotTools from '@/tools/hubspot/index'
39+ import { hubspotListAssociationsTool } from '@/tools/hubspot/list_associations'
2740import type { ToolConfig } from '@/tools/types'
2841
2942/**
3043 * The bare `.` and `..` entries are the whole point: their omission is why an
3144 * `encodeURIComponent`-only fix looks correct while the hole stays live.
3245 */
33- const TRAVERSAL_IDS = [
46+ const TRAVERSAL_VALUES = [
3447 '..' ,
3548 '.' ,
3649 ' .. ' ,
@@ -57,10 +70,17 @@ const LEGITIMATE_IDS = [
5770 'hs_object_id' ,
5871] as const
5972
60- const SAFE_ID = 'SAFEID'
73+ /** The value the parameter under test carries when a path is being mapped. */
74+ const TARGET = 'TARGETVALUE'
75+
76+ /** The value every *other* string parameter is pinned to while one is fuzzed. */
77+ const SIBLING = 'SIBLINGVALUE'
6178
6279type AnyTool = ToolConfig < any , any >
6380
81+ /** Parameters that carry credentials or the host, never a path segment. */
82+ const FIXED_PARAMS : Record < string , unknown > = { accessToken : 'token' }
83+
6484function isHubSpotTool ( value : unknown ) : value is AnyTool {
6585 return (
6686 typeof value === 'object' &&
@@ -70,112 +90,182 @@ function isHubSpotTool(value: unknown): value is AnyTool {
7090 )
7191}
7292
93+ /** The placeholder a sibling parameter holds, chosen so the URL still builds. */
94+ function siblingValue ( type : string | undefined ) : unknown {
95+ if ( type === 'json' || type === 'array' ) return [ ]
96+ if ( type === 'number' ) return 1
97+ if ( type === 'boolean' ) return false
98+ return SIBLING
99+ }
100+
73101/**
74- * Builds a param object for a tool, filling every declared string param with
75- * `value` so whichever one reaches the path is exercised.
102+ * Builds a param object with exactly one parameter under test.
103+ *
104+ * Every sibling is pinned to a safe placeholder, so a failure can only be
105+ * attributed to `target`. This is the whole difference from a fill-everything
106+ * sweep, where the first parameter to throw hides all the others.
76107 */
77- function buildParams ( tool : AnyTool , value : string ) : Record < string , unknown > {
78- const params : Record < string , unknown > = { accessToken : 'token' }
108+ function buildParams ( tool : AnyTool , target : string , value : string ) : Record < string , unknown > {
109+ const params : Record < string , unknown > = { ... FIXED_PARAMS }
79110 for ( const [ name , def ] of Object . entries ( tool . params ?? { } ) ) {
80- if ( name === 'accessToken' ) continue
81- const type = ( def as { type ?: string } ) . type
82- if ( type === 'json' || type === 'array' ) {
83- params [ name ] = [ ]
84- } else if ( type === 'number' ) {
85- params [ name ] = 1
86- } else if ( type === 'boolean' ) {
87- params [ name ] = false
88- } else {
89- params [ name ] = value
90- }
111+ if ( name in FIXED_PARAMS ) continue
112+ params [ name ] = name === target ? value : siblingValue ( ( def as { type ?: string } ) . type )
91113 }
92114 return params
93115}
94116
95- function buildUrl ( tool : AnyTool , value : string ) : URL {
117+ function buildUrl ( tool : AnyTool , target : string , value : string ) : URL {
96118 const url = tool . request ?. url
97119 if ( typeof url !== 'function' ) {
98120 throw new Error ( `${ tool . id } does not build its URL from params` )
99121 }
100- return new URL ( url ( buildParams ( tool , value ) as any ) )
122+ return new URL ( url ( buildParams ( tool , target , value ) as any ) )
101123}
102124
103125function segmentsOf ( pathname : string ) : string [ ] {
104126 return pathname . split ( '/' )
105127}
106128
107- const DYNAMIC_PATH_TOOLS = Object . values ( hubspotTools )
129+ /** True when `target` actually lands in the path rather than the query string. */
130+ function reachesPath ( tool : AnyTool , target : string ) : boolean {
131+ try {
132+ return buildUrl ( tool , target , TARGET ) . pathname . includes ( TARGET )
133+ } catch {
134+ return false
135+ }
136+ }
137+
138+ interface PathParamCase {
139+ name : string
140+ param : string
141+ tool : AnyTool
142+ }
143+
144+ const PATH_PARAM_CASES : PathParamCase [ ] = Object . values ( hubspotTools )
108145 . filter ( isHubSpotTool )
109146 . filter ( ( tool ) => typeof tool . request ?. url === 'function' )
110- . filter ( ( tool ) => {
111- try {
112- return buildUrl ( tool , SAFE_ID ) . pathname . includes ( SAFE_ID )
113- } catch {
114- return false
115- }
116- } )
117- . map ( ( tool ) => ( { name : tool . id , tool } ) )
147+ . flatMap ( ( tool ) =>
148+ Object . keys ( tool . params ?? { } )
149+ . filter ( ( param ) => ! ( param in FIXED_PARAMS ) )
150+ . filter ( ( param ) => reachesPath ( tool , param ) )
151+ . map ( ( param ) => ( { name : `${ tool . id } / ${ param } ` , param, tool } ) )
152+ )
118153
119- describe ( 'hubspot path-id traversal safety' , ( ) => {
120- it ( 'covers every HubSpot tool that interpolates an id into its path ' , ( ) => {
121- expect ( DYNAMIC_PATH_TOOLS . length ) . toBeGreaterThanOrEqual ( 28 )
154+ describe ( 'hubspot path-parameter traversal safety' , ( ) => {
155+ it ( 'covers every (hubspot tool, path parameter) pair ' , ( ) => {
156+ expect ( PATH_PARAM_CASES . length ) . toBeGreaterThanOrEqual ( 35 )
122157 } )
123158
124- describe . each ( DYNAMIC_PATH_TOOLS ) ( '$name' , ( { tool } ) => {
125- const baseline = segmentsOf ( buildUrl ( tool , SAFE_ID ) . pathname )
159+ describe . each ( PATH_PARAM_CASES ) ( '$name' , ( { tool, param } ) => {
160+ const baseline = segmentsOf ( buildUrl ( tool , param , TARGET ) . pathname )
126161
127- it . each ( TRAVERSAL_IDS ) ( 'cannot reshape the path with %j' , ( value ) => {
162+ it . each ( TRAVERSAL_VALUES ) ( 'cannot reshape the path with %j' , ( value ) => {
128163 let url : URL
129164 try {
130- url = buildUrl ( tool , value )
165+ url = buildUrl ( tool , param , value )
131166 } catch {
132167 return
133168 }
134169
135170 expect ( url . origin ) . toBe ( 'https://api.hubapi.com' )
171+ expect ( url . pathname . startsWith ( '/' ) ) . toBe ( true )
136172
137173 const actual = segmentsOf ( url . pathname )
138174 expect ( actual ) . toHaveLength ( baseline . length )
139175 baseline . forEach ( ( segment , index ) => {
140- if ( segment === SAFE_ID ) return
176+ if ( segment === TARGET ) return
141177 expect ( actual [ index ] ) . toBe ( segment )
142178 } )
143179 } )
144180
181+ /**
182+ * The shape check above cannot see a bare dot in the *final* segment: `x/.`
183+ * normalizes to `x/`, which keeps the segment count and leaves every other
184+ * segment intact. Rejection is the only observable difference, so assert it
185+ * directly for every parameter rather than only the hand-picked ones below.
186+ */
187+ it . each ( [ '.' , '..' ] ) ( 'rejects the bare %j segment' , ( value ) => {
188+ expect ( ( ) => buildUrl ( tool , param , value ) ) . toThrow ( / p a t h t r a v e r s a l / )
189+ } )
190+
145191 it . each ( LEGITIMATE_IDS ) ( 'passes %j through unchanged' , ( value ) => {
146- const actual = segmentsOf ( buildUrl ( tool , value ) . pathname )
192+ const actual = segmentsOf ( buildUrl ( tool , param , value ) . pathname )
147193
148194 expect ( actual ) . toHaveLength ( baseline . length )
149195 baseline . forEach ( ( segment , index ) => {
150- expect ( actual [ index ] ) . toBe ( segment === SAFE_ID ? value : segment )
196+ expect ( actual [ index ] ) . toBe ( segment === TARGET ? value : segment )
151197 } )
152198 } )
153199 } )
154200} )
155201
156- const HIGH_RISK_TOOLS : ReadonlyArray < { name : string ; tool : AnyTool } > = [
157- { name : 'hubspot_delete_contact' , tool : hubspotDeleteContactTool } ,
158- { name : 'hubspot_delete_company' , tool : hubspotDeleteCompanyTool } ,
159- { name : 'hubspot_delete_deal' , tool : hubspotDeleteDealTool } ,
160- { name : 'hubspot_delete_association' , tool : hubspotDeleteAssociationTool } ,
202+ const HIGH_RISK_CASES : ReadonlyArray < { name : string ; tool : AnyTool ; param : string } > = [
203+ { name : 'hubspot_delete_contact' , tool : hubspotDeleteContactTool , param : 'contactId' } ,
204+ { name : 'hubspot_delete_company' , tool : hubspotDeleteCompanyTool , param : 'companyId' } ,
205+ { name : 'hubspot_delete_deal' , tool : hubspotDeleteDealTool , param : 'dealId' } ,
206+ { name : 'hubspot_delete_association' , tool : hubspotDeleteAssociationTool , param : 'objectId' } ,
161207]
162208
163- describe . each ( HIGH_RISK_TOOLS ) ( '$name id path safety' , ( { tool } ) => {
209+ describe . each ( HIGH_RISK_CASES ) ( '$name path safety' , ( { tool, param } ) => {
164210 it ( 'rejects a bare dot-dot segment instead of silently popping the resource' , ( ) => {
165- expect ( ( ) => buildUrl ( tool , '..' ) ) . toThrow ( / p a t h t r a v e r s a l / )
211+ expect ( ( ) => buildUrl ( tool , param , '..' ) ) . toThrow ( / p a t h t r a v e r s a l / )
166212 } )
167213
168214 it ( 'rejects a bare dot segment' , ( ) => {
169- expect ( ( ) => buildUrl ( tool , '.' ) ) . toThrow ( / p a t h t r a v e r s a l / )
215+ expect ( ( ) => buildUrl ( tool , param , '.' ) ) . toThrow ( / p a t h t r a v e r s a l / )
170216 } )
171217
172- it ( 'does not let the id inject query parameters' , ( ) => {
173- const url = buildUrl ( tool , '12345?properties=email' )
218+ it ( 'does not let the value inject query parameters' , ( ) => {
219+ const url = buildUrl ( tool , param , '12345?properties=email' )
174220
175221 expect ( url . searchParams . get ( 'properties' ) ) . toBeNull ( )
176222 } )
177223
178- it ( 'preserves a legitimate numeric id verbatim after trimming' , ( ) => {
179- expect ( buildUrl ( tool , ' 12345 ' ) . pathname ) . toContain ( '/12345' )
224+ it ( 'preserves a legitimate value verbatim after trimming' , ( ) => {
225+ expect ( buildUrl ( tool , param , ' 12345 ' ) . pathname ) . toContain ( `/${ '12345' } ` )
226+ } )
227+ } )
228+
229+ /**
230+ * The regression this file exists to prevent. A fill-everything sweep reported
231+ * these tools as covered while only the *first* of their four path parameters
232+ * was ever reached, so reverting any of the other three produced no failure.
233+ */
234+ describe ( 'association tools expose every path parameter separately' , ( ) => {
235+ const ASSOCIATION_TOOLS = [
236+ {
237+ name : 'hubspot_create_association' ,
238+ tool : hubspotCreateAssociationTool ,
239+ params : [ 'objectType' , 'objectId' , 'toObjectType' , 'toObjectId' ] ,
240+ } ,
241+ {
242+ name : 'hubspot_delete_association' ,
243+ tool : hubspotDeleteAssociationTool ,
244+ params : [ 'objectType' , 'objectId' , 'toObjectType' , 'toObjectId' ] ,
245+ } ,
246+ {
247+ name : 'hubspot_list_associations' ,
248+ tool : hubspotListAssociationsTool ,
249+ params : [ 'objectType' , 'objectId' , 'toObjectType' ] ,
250+ } ,
251+ ] as const
252+
253+ it . each ( ASSOCIATION_TOOLS ) (
254+ '$name contributes every path parameter it interpolates' ,
255+ ( { tool, params } ) => {
256+ const covered = PATH_PARAM_CASES . filter ( ( entry ) => entry . tool === tool ) . map (
257+ ( entry ) => entry . param
258+ )
259+
260+ expect ( [ ...covered ] . sort ( ) ) . toEqual ( [ ...params ] . sort ( ) )
261+ }
262+ )
263+
264+ it . each (
265+ ASSOCIATION_TOOLS . flatMap ( ( { name, tool, params } ) =>
266+ params . map ( ( param ) => ( { name, tool, param } ) )
267+ )
268+ ) ( '$name rejects a dot-dot segment in $param' , ( { tool, param } ) => {
269+ expect ( ( ) => buildUrl ( tool , param , '..' ) ) . toThrow ( / p a t h t r a v e r s a l / )
180270 } )
181271} )
0 commit comments