@@ -17,6 +17,23 @@ import {
1717
1818// spyOn approach works with vitest forks pool for cross-directory imports
1919const mockFetch = vi . fn ( ) ;
20+
21+ // Build a Response-like `body` stream from a string so the production code exercises the
22+ // bounded body readers (readProviderTextResponse / readResponseTextLimited) instead of an
23+ // unbounded res.text(). Kept local to this file to avoid touching shared HTTP test mocks.
24+ function bodyStream ( text : string ) : { body : ReadableStream < Uint8Array > } {
25+ const bytes = new TextEncoder ( ) . encode ( text ) ;
26+ return {
27+ body : new ReadableStream < Uint8Array > ( {
28+ start ( controller ) {
29+ if ( bytes . byteLength > 0 ) {
30+ controller . enqueue ( bytes ) ;
31+ }
32+ controller . close ( ) ;
33+ } ,
34+ } ) ,
35+ } ;
36+ }
2037const wsMockState = vi . hoisted ( ( ) => ( {
2138 behavior : "close" as "close" | "open" | "error" | "unexpected-response" ,
2239 urls : [ ] as string [ ] ,
@@ -223,7 +240,7 @@ describe("containerRestRequest", () => {
223240 mockFetch . mockResolvedValue ( {
224241 ok : true ,
225242 status : 200 ,
226- text : async ( ) => JSON . stringify ( { version : "1.0" } ) ,
243+ ... bodyStream ( JSON . stringify ( { version : "1.0" } ) ) ,
227244 } ) ;
228245
229246 const result = await containerRestRequest ( "/v1/about" , { baseUrl : "http://localhost:8080" } ) ;
@@ -236,7 +253,7 @@ describe("containerRestRequest", () => {
236253 mockFetch . mockResolvedValue ( {
237254 ok : true ,
238255 status : 201 ,
239- text : async ( ) => "" ,
256+ ... bodyStream ( "" ) ,
240257 } ) ;
241258
242259 await containerRestRequest ( "/v2/send" , { baseUrl : "http://localhost:8080" } , "POST" , {
@@ -259,7 +276,7 @@ describe("containerRestRequest", () => {
259276 mockFetch . mockResolvedValue ( {
260277 ok : true ,
261278 status : 201 ,
262- text : async ( ) => JSON . stringify ( { timestamp : 1700000000000 } ) ,
279+ ... bodyStream ( JSON . stringify ( { timestamp : 1700000000000 } ) ) ,
263280 } ) ;
264281
265282 const result = await containerRestRequest (
@@ -289,7 +306,7 @@ describe("containerRestRequest", () => {
289306 ok : false ,
290307 status : 500 ,
291308 statusText : "Internal Server Error" ,
292- text : async ( ) => "Server error details" ,
309+ ... bodyStream ( "Server error details" ) ,
293310 } ) ;
294311
295312 await expect (
@@ -301,7 +318,7 @@ describe("containerRestRequest", () => {
301318 mockFetch . mockResolvedValue ( {
302319 ok : true ,
303320 status : 200 ,
304- text : async ( ) => "" ,
321+ ... bodyStream ( "" ) ,
305322 } ) ;
306323
307324 const result = await containerRestRequest ( "/v1/about" , { baseUrl : "http://localhost:8080" } ) ;
@@ -312,7 +329,7 @@ describe("containerRestRequest", () => {
312329 mockFetch . mockResolvedValue ( {
313330 ok : true ,
314331 status : 200 ,
315- text : async ( ) => "{}" ,
332+ ... bodyStream ( "{}" ) ,
316333 } ) ;
317334
318335 await containerRestRequest ( "/v1/about" , { baseUrl : "http://localhost:8080" , timeoutMs : 5000 } ) ;
@@ -332,7 +349,7 @@ describe("containerRestRequest", () => {
332349 mockFetch . mockResolvedValue ( {
333350 ok : true ,
334351 status : 200 ,
335- text : async ( ) => "{}" ,
352+ ... bodyStream ( "{}" ) ,
336353 } ) ;
337354
338355 await containerRestRequest ( "/v1/about" , {
@@ -357,7 +374,7 @@ describe("containerSendMessage", () => {
357374 mockFetch . mockResolvedValue ( {
358375 ok : true ,
359376 status : 200 ,
360- text : async ( ) => JSON . stringify ( { timestamp : "1700000000000" } ) ,
377+ ... bodyStream ( JSON . stringify ( { timestamp : "1700000000000" } ) ) ,
361378 } ) ;
362379
363380 const result = await containerSendMessage ( {
@@ -382,7 +399,7 @@ describe("containerSendMessage", () => {
382399 mockFetch . mockResolvedValue ( {
383400 ok : true ,
384401 status : 200 ,
385- text : async ( ) => JSON . stringify ( { timestamp : "not-a-number" } ) ,
402+ ... bodyStream ( JSON . stringify ( { timestamp : "not-a-number" } ) ) ,
386403 } ) ;
387404
388405 await expect (
@@ -401,7 +418,7 @@ describe("containerSendMessage", () => {
401418 mockFetch . mockResolvedValue ( {
402419 ok : true ,
403420 status : 200 ,
404- text : async ( ) => JSON . stringify ( { timestamp } ) ,
421+ ... bodyStream ( JSON . stringify ( { timestamp } ) ) ,
405422 } ) ;
406423
407424 await expect (
@@ -419,7 +436,7 @@ describe("containerSendMessage", () => {
419436 mockFetch . mockResolvedValue ( {
420437 ok : true ,
421438 status : 200 ,
422- text : async ( ) => JSON . stringify ( { } ) ,
439+ ... bodyStream ( JSON . stringify ( { } ) ) ,
423440 } ) ;
424441
425442 await containerSendMessage ( {
@@ -440,7 +457,7 @@ describe("containerSendMessage", () => {
440457 mockFetch . mockResolvedValue ( {
441458 ok : true ,
442459 status : 200 ,
443- text : async ( ) => JSON . stringify ( { } ) ,
460+ ... bodyStream ( JSON . stringify ( { } ) ) ,
444461 } ) ;
445462
446463 await containerSendMessage ( {
@@ -459,7 +476,7 @@ describe("containerSendMessage", () => {
459476 mockFetch . mockResolvedValue ( {
460477 ok : true ,
461478 status : 200 ,
462- text : async ( ) => JSON . stringify ( { } ) ,
479+ ... bodyStream ( JSON . stringify ( { } ) ) ,
463480 } ) ;
464481
465482 await containerSendMessage ( {
@@ -488,7 +505,7 @@ describe("containerSendMessage", () => {
488505 mockFetch . mockResolvedValue ( {
489506 ok : true ,
490507 status : 200 ,
491- text : async ( ) => JSON . stringify ( { } ) ,
508+ ... bodyStream ( JSON . stringify ( { } ) ) ,
492509 } ) ;
493510
494511 await containerSendMessage ( {
@@ -799,7 +816,7 @@ describe("containerRestRequest edge cases", () => {
799816 ok : false ,
800817 status : 500 ,
801818 statusText : "Internal Server Error" ,
802- text : async ( ) => "" ,
819+ ... bodyStream ( "" ) ,
803820 } ) ;
804821
805822 await expect (
@@ -811,13 +828,103 @@ describe("containerRestRequest edge cases", () => {
811828 mockFetch . mockResolvedValue ( {
812829 ok : true ,
813830 status : 200 ,
814- text : async ( ) => "not-valid-json" ,
831+ ... bodyStream ( "not-valid-json" ) ,
815832 } ) ;
816833
817834 await expect (
818835 containerRestRequest ( "/v1/about" , { baseUrl : "http://localhost:8080" } ) ,
819836 ) . rejects . toThrow ( ) ;
820837 } ) ;
838+
839+ it ( "fails closed when the success body exceeds the response size cap" , async ( ) => {
840+ // Drive the real bounded reader with a >16 MiB stream. Pull lazily so the cap
841+ // (16 MiB) trips and cancels the stream long before 20 MiB is materialized.
842+ const ONE_MIB = new Uint8Array ( 1024 * 1024 ) ;
843+ let emitted = 0 ;
844+ const stream = new ReadableStream < Uint8Array > ( {
845+ pull ( controller ) {
846+ if ( emitted >= 20 ) {
847+ controller . close ( ) ;
848+ return ;
849+ }
850+ emitted += 1 ;
851+ controller . enqueue ( ONE_MIB ) ;
852+ } ,
853+ } ) ;
854+ mockFetch . mockResolvedValue ( {
855+ ok : true ,
856+ status : 200 ,
857+ headers : new Headers ( ) ,
858+ body : stream ,
859+ } ) ;
860+
861+ await expect (
862+ containerRestRequest ( "/v1/about" , { baseUrl : "http://localhost:8080" } ) ,
863+ ) . rejects . toThrow ( / e x c e e d s \d + b y t e s / ) ;
864+ // The stream must have been cancelled at the cap, not drained to completion.
865+ expect ( emitted ) . toBeLessThan ( 20 ) ;
866+ } ) ;
867+
868+ it ( "bounds the error body so a huge failure response cannot be buffered whole" , async ( ) => {
869+ const HUGE = "x" . repeat ( 1024 * 1024 ) ; // 1 MiB of error text
870+ mockFetch . mockResolvedValue ( {
871+ ok : false ,
872+ status : 500 ,
873+ statusText : "Internal Server Error" ,
874+ ...bodyStream ( HUGE ) ,
875+ } ) ;
876+
877+ await containerRestRequest ( "/v2/send" , { baseUrl : "http://localhost:8080" } , "POST" ) . then (
878+ ( ) => {
879+ throw new Error ( "expected containerRestRequest to reject" ) ;
880+ } ,
881+ ( err : unknown ) => {
882+ const message = err instanceof Error ? err . message : String ( err ) ;
883+ expect ( message . startsWith ( "Signal REST 500:" ) ) . toBe ( true ) ;
884+ // readResponseTextLimited truncates the diagnostic body well below the 1 MiB payload.
885+ expect ( message . length ) . toBeLessThan ( 64 * 1024 ) ;
886+ } ,
887+ ) ;
888+ } ) ;
889+
890+ it ( "parses a large but under-cap success body without truncation" , async ( ) => {
891+ // Regression guard: a legitimate multi-MiB JSON response (well under the 16 MiB
892+ // cap) must still be read in full and parsed intact — the bound must not clip
893+ // valid container payloads. Build ~4 MiB of real JSON.
894+ const items = Array . from ( { length : 50_000 } , ( _ , i ) => ( {
895+ id : i ,
896+ note : "signal-container-payload-entry" ,
897+ } ) ) ;
898+ const payload = JSON . stringify ( { items } ) ;
899+ expect ( payload . length ) . toBeGreaterThan ( 2 * 1024 * 1024 ) ;
900+ expect ( payload . length ) . toBeLessThan ( 16 * 1024 * 1024 ) ;
901+ mockFetch . mockResolvedValue ( {
902+ ok : true ,
903+ status : 200 ,
904+ ...bodyStream ( payload ) ,
905+ } ) ;
906+
907+ const result = await containerRestRequest < { items : Array < { id : number } > } > ( "/v1/about" , {
908+ baseUrl : "http://localhost:8080" ,
909+ } ) ;
910+ // Full body round-trips: first and last entries survive, count is exact.
911+ expect ( result . items ) . toHaveLength ( 50_000 ) ;
912+ expect ( result . items [ 0 ] ?. id ) . toBe ( 0 ) ;
913+ expect ( result . items [ 49_999 ] ?. id ) . toBe ( 49_999 ) ;
914+ } ) ;
915+
916+ it ( "returns undefined for an empty success body via the bounded reader" , async ( ) => {
917+ // The bounded reader must preserve the existing empty-body -> undefined contract
918+ // (no spurious JSON.parse("") throw) so well-behaved 200/empty responses are unchanged.
919+ mockFetch . mockResolvedValue ( {
920+ ok : true ,
921+ status : 200 ,
922+ ...bodyStream ( "" ) ,
923+ } ) ;
924+
925+ const result = await containerRestRequest ( "/v1/about" , { baseUrl : "http://localhost:8080" } ) ;
926+ expect ( result ) . toBeUndefined ( ) ;
927+ } ) ;
821928} ) ;
822929
823930describe ( "streamContainerEvents" , ( ) => {
@@ -869,7 +976,7 @@ describe("containerSendReaction", () => {
869976 mockFetch . mockResolvedValue ( {
870977 ok : true ,
871978 status : 200 ,
872- text : async ( ) => JSON . stringify ( { timestamp : 1700000000000 } ) ,
979+ ... bodyStream ( JSON . stringify ( { timestamp : 1700000000000 } ) ) ,
873980 } ) ;
874981
875982 const result = await containerSendReaction ( {
@@ -905,7 +1012,7 @@ describe("containerRpcRequest reactions", () => {
9051012 mockFetch . mockResolvedValue ( {
9061013 ok : true ,
9071014 status : 200 ,
908- text : async ( ) => JSON . stringify ( { } ) ,
1015+ ... bodyStream ( JSON . stringify ( { } ) ) ,
9091016 } ) ;
9101017
9111018 await containerRpcRequest (
@@ -937,7 +1044,7 @@ describe("containerRemoveReaction", () => {
9371044 mockFetch . mockResolvedValue ( {
9381045 ok : true ,
9391046 status : 200 ,
940- text : async ( ) => JSON . stringify ( { timestamp : 1700000000000 } ) ,
1047+ ... bodyStream ( JSON . stringify ( { timestamp : 1700000000000 } ) ) ,
9411048 } ) ;
9421049
9431050 const result = await containerRemoveReaction ( {
0 commit comments