@@ -40,6 +40,25 @@ describe("handleControlUiHttpRequest", () => {
4040 expect ( params . end ) . toHaveBeenCalledWith ( "Not Found" ) ;
4141 }
4242
43+ function expectUnhandledRoutes ( params : {
44+ urls : string [ ] ;
45+ method : "GET" | "POST" ;
46+ rootPath : string ;
47+ basePath ?: string ;
48+ expectationLabel : string ;
49+ } ) {
50+ for ( const url of params . urls ) {
51+ const { handled, end } = runControlUiRequest ( {
52+ url,
53+ method : params . method ,
54+ rootPath : params . rootPath ,
55+ ...( params . basePath ? { basePath : params . basePath } : { } ) ,
56+ } ) ;
57+ expect ( handled , `${ params . expectationLabel } : ${ url } ` ) . toBe ( false ) ;
58+ expect ( end , `${ params . expectationLabel } : ${ url } ` ) . not . toHaveBeenCalled ( ) ;
59+ }
60+ }
61+
4362 function runControlUiRequest ( params : {
4463 url : string ;
4564 method : "GET" | "HEAD" | "POST" ;
@@ -147,53 +166,80 @@ describe("handleControlUiHttpRequest", () => {
147166 } ) ;
148167 } ) ;
149168
150- it ( "serves bootstrap config JSON" , async ( ) => {
169+ it . each ( [
170+ {
171+ name : "at root" ,
172+ url : CONTROL_UI_BOOTSTRAP_CONFIG_PATH ,
173+ expectedBasePath : "" ,
174+ assistantName : "</script><script>alert(1)//" ,
175+ assistantAvatar : "</script>.png" ,
176+ expectedAvatarUrl : "/avatar/main" ,
177+ } ,
178+ {
179+ name : "under basePath" ,
180+ url : `/openclaw${ CONTROL_UI_BOOTSTRAP_CONFIG_PATH } ` ,
181+ basePath : "/openclaw" ,
182+ expectedBasePath : "/openclaw" ,
183+ assistantName : "Ops" ,
184+ assistantAvatar : "ops.png" ,
185+ expectedAvatarUrl : "/openclaw/avatar/main" ,
186+ } ,
187+ ] ) ( "serves bootstrap config JSON $name" , async ( testCase ) => {
151188 await withControlUiRoot ( {
152189 fn : async ( tmp ) => {
153190 const { res, end } = makeMockHttpResponse ( ) ;
154191 const handled = handleControlUiHttpRequest (
155- { url : CONTROL_UI_BOOTSTRAP_CONFIG_PATH , method : "GET" } as IncomingMessage ,
192+ { url : testCase . url , method : "GET" } as IncomingMessage ,
156193 res ,
157194 {
195+ ...( testCase . basePath ? { basePath : testCase . basePath } : { } ) ,
158196 root : { kind : "resolved" , path : tmp } ,
159197 config : {
160198 agents : { defaults : { workspace : tmp } } ,
161- ui : { assistant : { name : "</script><script>alert(1)//" , avatar : "</script>.png" } } ,
199+ ui : {
200+ assistant : {
201+ name : testCase . assistantName ,
202+ avatar : testCase . assistantAvatar ,
203+ } ,
204+ } ,
162205 } ,
163206 } ,
164207 ) ;
165208 expect ( handled ) . toBe ( true ) ;
166209 const parsed = parseBootstrapPayload ( end ) ;
167- expect ( parsed . basePath ) . toBe ( "" ) ;
168- expect ( parsed . assistantName ) . toBe ( "</script><script>alert(1)//" ) ;
169- expect ( parsed . assistantAvatar ) . toBe ( "/avatar/main" ) ;
210+ expect ( parsed . basePath ) . toBe ( testCase . expectedBasePath ) ;
211+ expect ( parsed . assistantName ) . toBe ( testCase . assistantName ) ;
212+ expect ( parsed . assistantAvatar ) . toBe ( testCase . expectedAvatarUrl ) ;
170213 expect ( parsed . assistantAgentId ) . toBe ( "main" ) ;
171214 } ,
172215 } ) ;
173216 } ) ;
174217
175- it ( "serves bootstrap config JSON under basePath" , async ( ) => {
218+ it . each ( [
219+ {
220+ name : "at root" ,
221+ url : CONTROL_UI_BOOTSTRAP_CONFIG_PATH ,
222+ } ,
223+ {
224+ name : "under basePath" ,
225+ url : `/openclaw${ CONTROL_UI_BOOTSTRAP_CONFIG_PATH } ` ,
226+ basePath : "/openclaw" ,
227+ } ,
228+ ] ) ( "serves bootstrap config HEAD $name without writing a body" , async ( testCase ) => {
176229 await withControlUiRoot ( {
177230 fn : async ( tmp ) => {
178231 const { res, end } = makeMockHttpResponse ( ) ;
179232 const handled = handleControlUiHttpRequest (
180- { url : `/openclaw ${ CONTROL_UI_BOOTSTRAP_CONFIG_PATH } ` , method : "GET " } as IncomingMessage ,
233+ { url : testCase . url , method : "HEAD " } as IncomingMessage ,
181234 res ,
182235 {
183- basePath : "/openclaw" ,
236+ ... ( testCase . basePath ? { basePath : testCase . basePath } : { } ) ,
184237 root : { kind : "resolved" , path : tmp } ,
185- config : {
186- agents : { defaults : { workspace : tmp } } ,
187- ui : { assistant : { name : "Ops" , avatar : "ops.png" } } ,
188- } ,
189238 } ,
190239 ) ;
191240 expect ( handled ) . toBe ( true ) ;
192- const parsed = parseBootstrapPayload ( end ) ;
193- expect ( parsed . basePath ) . toBe ( "/openclaw" ) ;
194- expect ( parsed . assistantName ) . toBe ( "Ops" ) ;
195- expect ( parsed . assistantAvatar ) . toBe ( "/openclaw/avatar/main" ) ;
196- expect ( parsed . assistantAgentId ) . toBe ( "main" ) ;
241+ expect ( res . statusCode ) . toBe ( 200 ) ;
242+ expect ( end . mock . calls [ 0 ] ?. length ?? - 1 ) . toBe ( 0 ) ;
197243 } ,
198244 } ) ;
199245 } ) ;
@@ -350,7 +396,20 @@ describe("handleControlUiHttpRequest", () => {
350396 } ) ;
351397 } ) ;
352398
353- it ( "rejects hardlinked asset files for custom/resolved roots (security boundary)" , async ( ) => {
399+ it . each ( [
400+ {
401+ name : "rejects hardlinked asset files for custom/resolved roots" ,
402+ rootKind : "resolved" as const ,
403+ expectedStatus : 404 ,
404+ expectedBody : "Not Found" ,
405+ } ,
406+ {
407+ name : "serves hardlinked asset files for bundled roots" ,
408+ rootKind : "bundled" as const ,
409+ expectedStatus : 200 ,
410+ expectedBody : "console.log('hi');" ,
411+ } ,
412+ ] ) ( "$name" , async ( testCase ) => {
354413 await withControlUiRoot ( {
355414 fn : async ( tmp ) => {
356415 const assetsDir = path . join ( tmp , "assets" ) ;
@@ -362,126 +421,88 @@ describe("handleControlUiHttpRequest", () => {
362421 url : "/assets/app.hl.js" ,
363422 method : "GET" ,
364423 rootPath : tmp ,
424+ rootKind : testCase . rootKind ,
365425 } ) ;
366426
367427 expect ( handled ) . toBe ( true ) ;
368- expect ( res . statusCode ) . toBe ( 404 ) ;
369- expect ( end ) . toHaveBeenCalledWith ( "Not Found" ) ;
428+ expect ( res . statusCode ) . toBe ( testCase . expectedStatus ) ;
429+ expect ( String ( end . mock . calls [ 0 ] ?. [ 0 ] ?? "" ) ) . toBe ( testCase . expectedBody ) ;
370430 } ,
371431 } ) ;
372432 } ) ;
373433
374- it ( "serves hardlinked asset files for bundled roots (pnpm global install )" , async ( ) => {
434+ it ( "does not handle POST to root-mounted paths (plugin webhook passthrough )" , async ( ) => {
375435 await withControlUiRoot ( {
376436 fn : async ( tmp ) => {
377- const assetsDir = path . join ( tmp , "assets" ) ;
378- await fs . mkdir ( assetsDir , { recursive : true } ) ;
379- await fs . writeFile ( path . join ( assetsDir , "app.js" ) , "console.log('hi');" ) ;
380- await fs . link ( path . join ( assetsDir , "app.js" ) , path . join ( assetsDir , "app.hl.js" ) ) ;
381-
382- const { res, end, handled } = runControlUiRequest ( {
383- url : "/assets/app.hl.js" ,
384- method : "GET" ,
437+ expectUnhandledRoutes ( {
438+ urls : [ "/bluebubbles-webhook" , "/custom-webhook" , "/callback" ] ,
439+ method : "POST" ,
385440 rootPath : tmp ,
386- rootKind : "bundled " ,
441+ expectationLabel : "POST should pass through to plugin handlers " ,
387442 } ) ;
388-
389- expect ( handled ) . toBe ( true ) ;
390- expect ( res . statusCode ) . toBe ( 200 ) ;
391- expect ( String ( end . mock . calls [ 0 ] ?. [ 0 ] ?? "" ) ) . toBe ( "console.log('hi');" ) ;
392- } ,
393- } ) ;
394- } ) ;
395-
396- it ( "does not handle POST to root-mounted paths (plugin webhook passthrough)" , async ( ) => {
397- await withControlUiRoot ( {
398- fn : async ( tmp ) => {
399- for ( const webhookPath of [ "/bluebubbles-webhook" , "/custom-webhook" , "/callback" ] ) {
400- const { res } = makeMockHttpResponse ( ) ;
401- const handled = handleControlUiHttpRequest (
402- { url : webhookPath , method : "POST" } as IncomingMessage ,
403- res ,
404- { root : { kind : "resolved" , path : tmp } } ,
405- ) ;
406- expect ( handled , `POST to ${ webhookPath } should pass through to plugin handlers` ) . toBe (
407- false ,
408- ) ;
409- }
410443 } ,
411444 } ) ;
412445 } ) ;
413446
414447 it ( "does not handle POST to paths outside basePath" , async ( ) => {
415448 await withControlUiRoot ( {
416449 fn : async ( tmp ) => {
417- const { res } = makeMockHttpResponse ( ) ;
418- const handled = handleControlUiHttpRequest (
419- { url : "/bluebubbles-webhook" , method : "POST" } as IncomingMessage ,
420- res ,
421- { basePath : "/openclaw" , root : { kind : "resolved" , path : tmp } } ,
422- ) ;
423- expect ( handled ) . toBe ( false ) ;
424- } ,
425- } ) ;
426- } ) ;
427-
428- it ( "does not handle /api paths when basePath is empty" , async ( ) => {
429- await withControlUiRoot ( {
430- fn : async ( tmp ) => {
431- for ( const apiPath of [ "/api" , "/api/sessions" , "/api/channels/nostr" ] ) {
432- const { handled } = runControlUiRequest ( {
433- url : apiPath ,
434- method : "GET" ,
435- rootPath : tmp ,
436- } ) ;
437- expect ( handled , `expected ${ apiPath } to not be handled` ) . toBe ( false ) ;
438- }
450+ expectUnhandledRoutes ( {
451+ urls : [ "/bluebubbles-webhook" ] ,
452+ method : "POST" ,
453+ rootPath : tmp ,
454+ basePath : "/openclaw" ,
455+ expectationLabel : "POST outside basePath should pass through" ,
456+ } ) ;
439457 } ,
440458 } ) ;
441459 } ) ;
442460
443- it ( "does not handle /plugins paths when basePath is empty" , async ( ) => {
461+ it . each ( [
462+ {
463+ name : "does not handle /api paths when basePath is empty" ,
464+ urls : [ "/api" , "/api/sessions" , "/api/channels/nostr" ] ,
465+ } ,
466+ {
467+ name : "does not handle /plugins paths when basePath is empty" ,
468+ urls : [ "/plugins" , "/plugins/diffs/view/abc/def" ] ,
469+ } ,
470+ ] ) ( "$name" , async ( testCase ) => {
444471 await withControlUiRoot ( {
445472 fn : async ( tmp ) => {
446- for ( const pluginPath of [ "/plugins" , "/plugins/diffs/view/abc/def" ] ) {
447- const { handled } = runControlUiRequest ( {
448- url : pluginPath ,
449- method : "GET" ,
450- rootPath : tmp ,
451- } ) ;
452- expect ( handled , `expected ${ pluginPath } to not be handled` ) . toBe ( false ) ;
453- }
473+ expectUnhandledRoutes ( {
474+ urls : testCase . urls ,
475+ method : "GET" ,
476+ rootPath : tmp ,
477+ expectationLabel : "expected route to not be handled" ,
478+ } ) ;
454479 } ,
455480 } ) ;
456481 } ) ;
457482
458483 it ( "falls through POST requests when basePath is empty" , async ( ) => {
459484 await withControlUiRoot ( {
460485 fn : async ( tmp ) => {
461- const { handled , end } = runControlUiRequest ( {
462- url : "/webhook/bluebubbles" ,
486+ expectUnhandledRoutes ( {
487+ urls : [ "/webhook/bluebubbles" ] ,
463488 method : "POST" ,
464489 rootPath : tmp ,
490+ expectationLabel : "POST webhook should fall through" ,
465491 } ) ;
466- expect ( handled ) . toBe ( false ) ;
467- expect ( end ) . not . toHaveBeenCalled ( ) ;
468492 } ,
469493 } ) ;
470494 } ) ;
471495
472496 it ( "falls through POST requests under configured basePath (plugin webhook passthrough)" , async ( ) => {
473497 await withControlUiRoot ( {
474498 fn : async ( tmp ) => {
475- for ( const route of [ "/openclaw" , "/openclaw/" , "/openclaw/some-page" ] ) {
476- const { handled, end } = runControlUiRequest ( {
477- url : route ,
478- method : "POST" ,
479- rootPath : tmp ,
480- basePath : "/openclaw" ,
481- } ) ;
482- expect ( handled , `POST to ${ route } should pass through to plugin handlers` ) . toBe ( false ) ;
483- expect ( end , `POST to ${ route } should not write a response` ) . not . toHaveBeenCalled ( ) ;
484- }
499+ expectUnhandledRoutes ( {
500+ urls : [ "/openclaw" , "/openclaw/" , "/openclaw/some-page" ] ,
501+ method : "POST" ,
502+ rootPath : tmp ,
503+ basePath : "/openclaw" ,
504+ expectationLabel : "POST under basePath should pass through to plugin handlers" ,
505+ } ) ;
485506 } ,
486507 } ) ;
487508 } ) ;
0 commit comments