@@ -708,26 +708,233 @@ describe('credential-bound OCI client', () => {
708708 expect ( JSON . stringify ( failure ) ) . not . toContain ( 'authorization' )
709709 } )
710710
711- it ( 'returns only selected safe headers and bounded Uint8Array bodies' , async ( ) => {
711+ it . each ( [ true , false ] ) ( 'keeps additional response headers opt-in: %s' , async ( requested ) => {
712+ const additionalHeaders = {
713+ 'opc-next-cursor' : 'opaque/next+cursor==%2F' ,
714+ 'content-length' : '3' ,
715+ 'last-modified' : 'Sat, 05 Sep 2026 12:00:00 GMT' ,
716+ 'content-md5' : 'content-md5==' ,
717+ 'opc-content-md5' : 'opc-content-md5==' ,
718+ 'opc-multipart-md5' : 'multipart-md5==' ,
719+ 'content-encoding' : 'identity' ,
720+ 'content-language' : 'en' ,
721+ 'content-disposition' : 'attachment; filename="report.csv"' ,
722+ 'cache-control' : 'private, max-age=60' ,
723+ 'storage-tier' : 'Archive' ,
724+ 'archival-state' : 'Restored' ,
725+ 'time-of-archival' : '2026-09-06T12:00:00Z' ,
726+ 'version-id' : 'opaque-version-id' ,
727+ 'is-delete-marker' : 'false' ,
728+ }
729+ const defaultHeaders = {
730+ 'content-type' : 'application/octet-stream' ,
731+ etag : 'etag-1' ,
732+ 'opc-request-id' : 'request-1' ,
733+ }
712734 mocks . secureFetch . mockResolvedValueOnce (
713735 secureResponse ( {
714736 status : 200 ,
715737 body : new Uint8Array ( [ 1 , 2 , 3 ] ) ,
716- headers : { etag : 'etag-1' , 'x-provider-secret' : 'hidden' } ,
738+ headers : { ... additionalHeaders , ... defaultHeaders , 'x-provider-secret' : 'hidden' } ,
717739 } )
718740 )
719741 const { client, endpoint } = await createPreparedClient ( )
720742 const result = await client . request ( {
721743 endpoint,
722744 method : 'GET' ,
723745 encodedPath : '/v1/test' ,
724- responseHeaders : [ 'etag' ] ,
746+ responseHeaders : requested
747+ ? [ 'ETAG' , ...Object . keys ( additionalHeaders ) . map ( ( name ) => name . toUpperCase ( ) ) ]
748+ : undefined ,
725749 timeoutMs : 10_000 ,
726750 maxResponseBytes : 3 ,
727751 } )
728752 expect ( [ ...result . body ] ) . toEqual ( [ 1 , 2 , 3 ] )
729- expect ( result . headers . etag ) . toBe ( 'etag-1' )
730- expect ( result . headers ) . not . toHaveProperty ( 'x-provider-secret' )
753+ expect ( result . headers ) . toEqual ( {
754+ ...defaultHeaders ,
755+ ...( requested ? additionalHeaders : { } ) ,
756+ } )
757+ expect ( Object . isFrozen ( result . headers ) ) . toBe ( true )
758+ } )
759+
760+ it ( 'retains an opaque next cursor when the message batch is empty' , async ( ) => {
761+ const cursor = 'opaque/next+cursor==%2F'
762+ mocks . secureFetch . mockResolvedValueOnce (
763+ secureResponse ( {
764+ body : '[]' ,
765+ headers : { 'opc-next-cursor' : cursor , 'opc-next-page' : 'not-a-message-cursor' } ,
766+ } )
767+ )
768+ const { client, endpoint } = await createPreparedClient ( )
769+ const result = await client . request ( {
770+ endpoint,
771+ method : 'GET' ,
772+ encodedPath : '/v1/messages' ,
773+ responseHeaders : [ 'opc-next-cursor' ] ,
774+ timeoutMs : 10_000 ,
775+ maxResponseBytes : 1024 ,
776+ } )
777+ expect ( new TextDecoder ( ) . decode ( result . body ) ) . toBe ( '[]' )
778+ expect ( result . headers ) . toEqual ( { 'opc-next-cursor' : cursor } )
779+ } )
780+
781+ it ( 'projects HEAD metadata without applying the body limit to the object size' , async ( ) => {
782+ mocks . secureFetch . mockResolvedValueOnce (
783+ secureResponse ( {
784+ headers : { 'content-length' : '1099511627776' , 'opc-meta-source' : 'head metadata' } ,
785+ } )
786+ )
787+ const { client, endpoint } = await createPreparedClient ( )
788+ const result = await client . request ( {
789+ endpoint,
790+ method : 'HEAD' ,
791+ encodedPath : '/v1/object' ,
792+ responseHeaders : [ 'content-length' , 'opc-meta-*' ] ,
793+ timeoutMs : 10_000 ,
794+ maxResponseBytes : 1 ,
795+ } )
796+ expect ( result . body . byteLength ) . toBe ( 0 )
797+ expect ( result . headers ) . toEqual ( {
798+ 'content-length' : '1099511627776' ,
799+ 'opc-meta-source' : 'head metadata' ,
800+ } )
801+ } )
802+
803+ it . each ( [ true , false ] ) (
804+ 'projects only explicitly requested object metadata: %s' ,
805+ async ( requested ) => {
806+ mocks . secureFetch . mockResolvedValueOnce (
807+ secureResponse ( {
808+ headers : {
809+ 'OPC-Meta-Source' : 'Mixed CASE / café' ,
810+ 'opc-meta-empty' : '' ,
811+ 'opc-meta-' : 'missing suffix' ,
812+ 'x-opc-meta-secret' : 'excluded' ,
813+ 'set-cookie' : 'excluded=secret' ,
814+ authorization : 'Bearer excluded-secret' ,
815+ } ,
816+ } )
817+ )
818+ const { client, endpoint } = await createPreparedClient ( )
819+ const result = await client . request ( {
820+ endpoint,
821+ method : 'GET' ,
822+ encodedPath : '/v1/object' ,
823+ responseHeaders : requested ? [ 'OPC-META-*' , 'opc-meta-*' ] : [ ] ,
824+ timeoutMs : 10_000 ,
825+ maxResponseBytes : 1024 ,
826+ } )
827+ expect ( result . headers ) . toEqual (
828+ requested
829+ ? {
830+ 'opc-meta-source' : 'Mixed CASE / café' ,
831+ 'opc-meta-empty' : '' ,
832+ }
833+ : { }
834+ )
835+ expect ( Object . isFrozen ( result . headers ) ) . toBe ( true )
836+ }
837+ )
838+
839+ it . each ( [ 4096 , 4097 ] ) ( 'bounds projected object metadata entries: %i' , async ( count ) => {
840+ const headers : Record < string , string > = { }
841+ for ( let index = 0 ; index < count ; index += 1 ) {
842+ headers [ `opc-meta-${ index } ` ] = ''
843+ }
844+ mocks . secureFetch . mockResolvedValueOnce ( secureResponse ( { headers } ) )
845+ const { client, endpoint } = await createPreparedClient ( )
846+ const pending = client . request ( {
847+ endpoint,
848+ method : 'GET' ,
849+ encodedPath : '/v1/object' ,
850+ responseHeaders : [ 'opc-meta-*' ] ,
851+ timeoutMs : 10_000 ,
852+ maxResponseBytes : 1024 ,
853+ } )
854+ if ( count === 4096 ) {
855+ const result = await pending
856+ expect ( result . headers ) . toEqual ( headers )
857+ expect ( Object . isFrozen ( result . headers ) ) . toBe ( true )
858+ } else {
859+ await expect ( pending ) . rejects . toMatchObject ( {
860+ code : 'response_too_large' ,
861+ message : 'OCI response exceeded the configured limit' ,
862+ } )
863+ }
864+ expect ( mocks . secureFetch ) . toHaveBeenCalledOnce ( )
865+ } )
866+
867+ it . each ( [ 65536 , 65537 ] ) ( 'bounds metadata names and values by UTF-8 bytes: %i' , async ( bytes ) => {
868+ const name = 'opc-meta-test'
869+ const valueBytes = bytes - Buffer . byteLength ( name , 'utf8' )
870+ const value = 'é' . repeat ( Math . floor ( valueBytes / 2 ) ) + 'x' . repeat ( valueBytes % 2 )
871+ expect ( Buffer . byteLength ( name + value , 'utf8' ) ) . toBe ( bytes )
872+ mocks . secureFetch . mockResolvedValueOnce ( secureResponse ( { headers : { [ name ] : value } } ) )
873+ const { client, endpoint } = await createPreparedClient ( )
874+ const pending = client . request ( {
875+ endpoint,
876+ method : 'GET' ,
877+ encodedPath : '/v1/object' ,
878+ responseHeaders : [ 'opc-meta-*' ] ,
879+ timeoutMs : 10_000 ,
880+ maxResponseBytes : 1024 ,
881+ } )
882+ if ( bytes === 65536 ) {
883+ expect ( ( await pending ) . headers ) . toEqual ( { [ name ] : value } )
884+ } else {
885+ await expect ( pending ) . rejects . toMatchObject ( {
886+ code : 'response_too_large' ,
887+ message : 'OCI response exceeded the configured limit' ,
888+ } )
889+ }
890+ } )
891+
892+ it ( 'applies the metadata byte limit across entries only when requested' , async ( ) => {
893+ const headers = {
894+ 'opc-meta-first' : 'a' . repeat ( 40_000 ) ,
895+ 'opc-meta-second' : 'b' . repeat ( 40_000 ) ,
896+ }
897+ mocks . secureFetch . mockResolvedValue ( secureResponse ( { headers } ) )
898+ const { client, endpoint } = await createPreparedClient ( )
899+ const request = {
900+ endpoint,
901+ method : 'GET' as const ,
902+ encodedPath : '/v1/object' ,
903+ timeoutMs : 10_000 ,
904+ maxResponseBytes : 1024 ,
905+ }
906+ expect ( ( await client . request ( request ) ) . headers ) . toEqual ( { } )
907+ mocks . secureFetch . mockResolvedValueOnce ( secureResponse ( { headers } ) )
908+ await expect (
909+ client . request ( { ...request , responseHeaders : [ 'opc-meta-*' ] } )
910+ ) . rejects . toMatchObject ( {
911+ code : 'response_too_large' ,
912+ message : 'OCI response exceeded the configured limit' ,
913+ } )
914+ } )
915+
916+ it . each ( [
917+ '*' ,
918+ 'opc-*' ,
919+ 'opc-meta-*suffix' ,
920+ 'opc-meta-' ,
921+ 'opc-meta-name' ,
922+ 'set-cookie' ,
923+ 'x-provider-secret' ,
924+ ] ) ( 'rejects unsupported header selectors before DNS or transport: %s' , async ( name ) => {
925+ const { client, endpoint } = await createPreparedClient ( )
926+ await expect (
927+ client . request ( {
928+ endpoint,
929+ method : 'GET' ,
930+ encodedPath : '/v1/object' ,
931+ responseHeaders : [ name ] ,
932+ timeoutMs : 10_000 ,
933+ maxResponseBytes : 1024 ,
934+ } )
935+ ) . rejects . toMatchObject ( { code : 'invalid_request' } )
936+ expect ( mocks . validateUrl ) . not . toHaveBeenCalled ( )
937+ expect ( mocks . secureFetch ) . not . toHaveBeenCalled ( )
731938 } )
732939
733940 it ( 'cancels and classifies a success body beyond the operation limit' , async ( ) => {
0 commit comments