1- /**
2- * Boundary test: fetchHttpJson error body is read under a byte cap.
3- *
4- * Proves that a streaming error response larger than 16 KiB is cancelled
5- * mid-stream instead of being fully buffered, preventing OOM on malformed
6- * upstream error pages from the browser control service.
7- */
81import http from "node:http" ;
92import { afterEach , beforeEach , describe , expect , it , vi } from "vitest" ;
10- import "../test-support/browser-security.mock.js" ;
113
12- // Replace fetchWithSsrFGuard with a lightweight pass-through so the test
13- // can drive fetchHttpJson against a real loopback HTTP server. The SSRF
14- // guard is tested separately; this test keeps the focus on the response
15- // body boundary.
16- vi . mock ( "openclaw/plugin-sdk/ssrf-runtime" , async ( ) => {
17- const actual = await vi . importActual < typeof import ( "openclaw/plugin-sdk/ssrf-runtime" ) > (
18- "openclaw/plugin-sdk/ssrf-runtime" ,
19- ) ;
20- return {
21- ...actual ,
22- fetchWithSsrFGuard : async ( params : {
23- url : string ;
24- init ?: RequestInit ;
25- signal ?: AbortSignal ;
26- } ) => ( {
27- response : await fetch ( params . url , {
28- ...params . init ,
29- signal : params . signal ,
30- } ) ,
31- finalUrl : params . url ,
32- release : async ( ) => { } ,
33- } ) ,
34- } ;
35- } ) ;
36-
37- // Config/auth mocks — the test hits a loopback URL so the auth path
38- // tries to look up config; stub it out to avoid side effects.
39- const mocks = vi . hoisted ( ( ) => ( {
4+ const authMocks = vi . hoisted ( ( ) => ( {
405 loadConfig : vi . fn ( ( ) => ( { } ) ) ,
416 resolveBrowserControlAuth : vi . fn ( ( ) => ( { } ) ) ,
427 getBridgeAuthForPort : vi . fn ( ( ) => undefined ) ,
438} ) ) ;
449
4510vi . mock ( "../config/config.js" , async ( ) => {
4611 const actual = await vi . importActual < typeof import ( "../config/config.js" ) > ( "../config/config.js" ) ;
47- return { ...actual , getRuntimeConfig : mocks . loadConfig , loadConfig : mocks . loadConfig } ;
12+ return { ...actual , getRuntimeConfig : authMocks . loadConfig , loadConfig : authMocks . loadConfig } ;
4813} ) ;
49-
5014vi . mock ( "./control-auth.js" , ( ) => ( {
51- resolveBrowserControlAuth : mocks . resolveBrowserControlAuth ,
15+ resolveBrowserControlAuth : authMocks . resolveBrowserControlAuth ,
5216} ) ) ;
53-
5417vi . mock ( "./bridge-auth-registry.js" , ( ) => ( {
55- getBridgeAuthForPort : mocks . getBridgeAuthForPort ,
18+ getBridgeAuthForPort : authMocks . getBridgeAuthForPort ,
5619} ) ) ;
5720
5821const { fetchBrowserJson } = await import ( "./client-fetch.js" ) ;
5922
60- /** Streaming chunks are large enough to exceed the 16 KiB cap in one chunk. */
61- const CHUNK_SIZE = 64 * 1024 ;
62-
63- /** Total bytes the server will stream before closing. */
64- const TOTAL_BODY_SIZE = 4 * 1024 * 1024 ;
23+ const STREAM_CHUNK = Buffer . alloc ( 4 * 1024 , "x" ) ;
24+ const STREAM_BODY_BYTES = 1024 * 1024 ;
6525
6626describe ( "fetchHttpJson error body boundary" , ( ) => {
6727 let server : http . Server ;
68- let port : number ;
28+ let baseUrl : string ;
29+ let streamClosed : Promise < void > ;
30+ let resolveStreamClosed : ( ) => void ;
31+ let smallConnectionClosed : Promise < void > ;
32+ let resolveSmallConnectionClosed : ( ) => void ;
33+ let streamCompleted : boolean ;
6934
7035 beforeEach ( async ( ) => {
71- vi . restoreAllMocks ( ) ;
7236 for ( const key of [
7337 "ALL_PROXY" ,
7438 "all_proxy" ,
@@ -79,96 +43,81 @@ describe("fetchHttpJson error body boundary", () => {
7943 ] ) {
8044 vi . stubEnv ( key , "" ) ;
8145 }
82- mocks . loadConfig . mockReturnValue ( { } ) ;
83- mocks . resolveBrowserControlAuth . mockReturnValue ( { } ) ;
84- mocks . getBridgeAuthForPort . mockReturnValue ( undefined ) ;
8546
86- // Start a loopback server that streams a large error body.
87- // No Content-Length header — the client must read until the cap,
88- // not until a known size.
89- server = http . createServer ( ( _req , res ) => {
90- res . writeHead ( 500 , { "Content-Type" : "text/html" } ) ;
47+ streamClosed = new Promise < void > ( ( resolve ) => {
48+ resolveStreamClosed = resolve ;
49+ } ) ;
50+ smallConnectionClosed = new Promise < void > ( ( resolve ) => {
51+ resolveSmallConnectionClosed = resolve ;
52+ } ) ;
53+ streamCompleted = false ;
54+ server = http . createServer ( ( req , res ) => {
55+ if ( req . url === "/small" ) {
56+ req . socket . once ( "close" , ( ) => resolveSmallConnectionClosed ( ) ) ;
57+ res . writeHead ( 500 , { "Content-Type" : "text/plain" } ) ;
58+ res . end ( "session expired" ) ;
59+ return ;
60+ }
61+
62+ res . writeHead ( 500 , { "Content-Type" : "text/plain" } ) ;
9163 let written = 0 ;
92- const chunk = Buffer . alloc ( CHUNK_SIZE , "x" ) ;
93- function writeChunk ( ) {
94- if ( written >= TOTAL_BODY_SIZE ) {
64+ let closed = false ;
65+ res . once ( "close" , ( ) => {
66+ closed = true ;
67+ resolveStreamClosed ( ) ;
68+ } ) ;
69+ const writeNext = ( ) => {
70+ if ( closed ) {
71+ return ;
72+ }
73+ if ( written >= STREAM_BODY_BYTES ) {
74+ streamCompleted = true ;
9575 res . end ( ) ;
9676 return ;
9777 }
98- const ok = res . write ( chunk ) ;
99- written += CHUNK_SIZE ;
100- if ( ok ) {
101- // Drain the microtask queue so the reader can consume, then
102- // schedule the next chunk through setTimeout to avoid starving
103- // the event loop when backpressure isn't applied.
104- setImmediate ( writeChunk ) ;
78+ written += STREAM_CHUNK . byteLength ;
79+ const writeMore = ( ) => setTimeout ( writeNext , 2 ) ;
80+ if ( res . write ( STREAM_CHUNK ) ) {
81+ writeMore ( ) ;
10582 } else {
106- res . once ( "drain" , writeChunk ) ;
83+ res . once ( "drain" , writeMore ) ;
10784 }
108- }
109- writeChunk ( ) ;
85+ } ;
86+ writeNext ( ) ;
11087 } ) ;
111-
11288 await new Promise < void > ( ( resolve ) => {
113- server . listen ( 0 , "127.0.0.1" , ( ) => resolve ( ) ) ;
89+ server . listen ( 0 , "127.0.0.1" , resolve ) ;
11490 } ) ;
115- port = ( server . address ( ) as { port : number } ) . port ;
91+ const address = server . address ( ) ;
92+ if ( ! address || typeof address === "string" ) {
93+ throw new Error ( "expected loopback server address" ) ;
94+ }
95+ baseUrl = `http://127.0.0.1:${ address . port } ` ;
11696 } ) ;
11797
11898 afterEach ( async ( ) => {
119- vi . unstubAllGlobals ( ) ;
12099 vi . unstubAllEnvs ( ) ;
121- if ( server ) {
122- await new Promise < void > ( ( resolve ) => {
123- server . close ( ( ) => resolve ( ) ) ;
124- } ) ;
125- }
100+ server . closeAllConnections ( ) ;
101+ await new Promise < void > ( ( resolve ) => {
102+ server . close ( ( ) => resolve ( ) ) ;
103+ } ) ;
126104 } ) ;
127105
128- it ( "cancels the stream after 16 KiB on a non-ok response" , async ( ) => {
129- const url = `http://127.0.0.1:${ port } /error` ;
130-
131- const err = await fetchBrowserJson ( url , { timeoutMs : 5000 } ) . catch ( ( e : unknown ) => e ) ;
132-
133- // Must throw — the server returned 500.
134- expect ( err ) . toBeInstanceOf ( Error ) ;
135- const message = ( err as Error ) . message ;
136-
137- // The error body snippet must be bounded at ~16 KiB. If unguarded,
138- // the raw res.text() would buffer the full 4 MiB streaming body
139- // into the error message. This assertion is the core invariant.
140- const messageBytes = Buffer . byteLength ( message , "utf8" ) ;
141- expect ( messageBytes ) . toBeLessThan ( 32 * 1024 ) ; // well under 4 MiB
142- expect ( messageBytes ) . toBeGreaterThan ( 0 ) ; // body was read
106+ it ( "cancels an overflowing stream and releases the guarded fetch" , async ( ) => {
107+ const error = await fetchBrowserJson ( `${ baseUrl } /large` ) . catch ( ( err : unknown ) => err ) ;
143108
144- // The body text should contain a snippet of the padding —
145- // readResponseTextLimited returns what it read (up to the cap).
146- expect ( message ) . toContain ( "x" ) ;
109+ expect ( error ) . toMatchObject ( { name : "BrowserServiceError" , message : "HTTP 500" } ) ;
110+ await expect ( streamClosed ) . resolves . toBeUndefined ( ) ;
111+ expect ( streamCompleted ) . toBe ( false ) ;
147112 } ) ;
148113
149- it ( "preserves the full error body when it fits under the cap" , async ( ) => {
150- // Small error body: the server returns a short text/plain message.
151- const snippetServer = http . createServer ( ( _req , res ) => {
152- res . writeHead ( 500 , { "Content-Type" : "text/plain" } ) ;
153- res . end ( "session expired" ) ;
154- } ) ;
155- const snippetPort = await new Promise < number > ( ( resolve ) => {
156- snippetServer . listen ( 0 , "127.0.0.1" , ( ) =>
157- resolve ( ( snippetServer . address ( ) as { port : number } ) . port ) ,
158- ) ;
159- } ) ;
160-
161- try {
162- const err = await fetchBrowserJson ( `http://127.0.0.1:${ snippetPort } /err` , {
163- timeoutMs : 5000 ,
164- } ) . catch ( ( e : unknown ) => e ) ;
114+ it ( "preserves a complete diagnostic body within the limit" , async ( ) => {
115+ const error = await fetchBrowserJson ( `${ baseUrl } /small` ) . catch ( ( err : unknown ) => err ) ;
165116
166- expect ( err ) . toBeInstanceOf ( Error ) ;
167- expect ( ( err as Error ) . message ) . toContain ( "session expired" ) ;
168- } finally {
169- await new Promise < void > ( ( resolve ) => {
170- snippetServer . close ( ( ) => resolve ( ) ) ;
171- } ) ;
172- }
117+ expect ( error ) . toMatchObject ( {
118+ name : "BrowserServiceError" ,
119+ message : "session expired" ,
120+ } ) ;
121+ await expect ( smallConnectionClosed ) . resolves . toBeUndefined ( ) ;
173122 } ) ;
174123} ) ;
0 commit comments