@@ -794,4 +794,247 @@ describe('Constructor', function () {
794794 nock . cleanAll ( ) ;
795795 } ) ;
796796 } ) ;
797+
798+ describe ( 'packKeychainsFFD' , function ( ) {
799+ let bitgo : BitGoAPI ;
800+
801+ before ( function ( ) {
802+ bitgo = new BitGoAPI ( { env : 'custom' , customRootURI : 'https://app.example.local' } ) ;
803+ } ) ;
804+
805+ function ffd (
806+ keychains : Record < string , string > ,
807+ v2Keychains : Record < string , string > ,
808+ maxBatchSizeBytes : number
809+ ) : Array < { v1Batch : Record < string , string > ; v2Batch : Record < string , string > ; sizeBytes : number } > {
810+ return ( bitgo as any ) . packKeychainsFFD ( keychains , v2Keychains , maxBatchSizeBytes ) ;
811+ }
812+
813+ it ( 'returns an empty array when both inputs are empty' , function ( ) {
814+ const bins = ffd ( { } , { } , 1024 ) ;
815+ bins . should . be . an . Array ( ) ;
816+ bins . should . have . length ( 0 ) ;
817+ } ) ;
818+
819+ it ( 'packs all items into a single bin when they fit within the limit' , function ( ) {
820+ // Each entry: 2-byte id + 10-byte value = 12 bytes; 5 entries = 60 bytes < 200 limit
821+ const keychains = { k1 : 'aaaaaaaaaa' , k2 : 'bbbbbbbbbb' , k3 : 'cccccccccc' } ;
822+ const v2Keychains = { v1 : 'dddddddddd' , v2 : 'eeeeeeeeee' } ;
823+ const bins = ffd ( keychains , v2Keychains , 200 ) ;
824+ bins . should . have . length ( 1 ) ;
825+ Object . keys ( bins [ 0 ] . v1Batch ) . should . have . length ( 3 ) ;
826+ Object . keys ( bins [ 0 ] . v2Batch ) . should . have . length ( 2 ) ;
827+ } ) ;
828+
829+ it ( 'splits into multiple bins when a single bin cannot fit all items' , function ( ) {
830+ // id='k1' (2 bytes) + 600-char value = 602 bytes; 2 entries exceed 700-byte limit
831+ const keychains = { k1 : 'x' . repeat ( 600 ) , k2 : 'y' . repeat ( 600 ) } ;
832+ const bins = ffd ( keychains , { } , 700 ) ;
833+ bins . should . have . length ( 2 ) ;
834+ } ) ;
835+
836+ it ( 'throws when a single item exceeds maxBatchSizeBytes' , function ( ) {
837+ // id='k1' (2 bytes) + 200-char value = 202 bytes > 100-byte limit
838+ const keychains = { k1 : 'x' . repeat ( 200 ) } ;
839+ try {
840+ ffd ( keychains , { } , 100 ) ;
841+ throw new Error ( 'Expected error not thrown' ) ;
842+ } catch ( e ) {
843+ e . message . should . match ( / e x c e e d s t h e m a x i m u m b a t c h s i z e / ) ;
844+ }
845+ } ) ;
846+
847+ it ( 'places V1 keychains in v1Batch and V2 keychains in v2Batch' , function ( ) {
848+ const keychains = { k1 : 'v1value' } ;
849+ const v2Keychains = { k2 : 'v2value' } ;
850+ const bins = ffd ( keychains , v2Keychains , 10000 ) ;
851+ bins . should . have . length ( 1 ) ;
852+ bins [ 0 ] . v1Batch . should . have . property ( 'k1' , 'v1value' ) ;
853+ bins [ 0 ] . v2Batch . should . have . property ( 'k2' , 'v2value' ) ;
854+ bins [ 0 ] . v1Batch . should . not . have . property ( 'k2' ) ;
855+ bins [ 0 ] . v2Batch . should . not . have . property ( 'k1' ) ;
856+ } ) ;
857+
858+ it ( 'uses FFD ordering so the largest item is packed first' , function ( ) {
859+ // 'big': id(3) + 497 bytes = 500 bytes; 's1','s2': id(2) + 98 bytes = 100 bytes each
860+ // maxBatchSizeBytes = 600
861+ // FFD order (descending): big(500), s1(100), s2(100)
862+ // bin0: big(500). s1: 500+100=600 <=600, fits. s2: 600+100=700 > 600, new bin1.
863+ // Result: bin0={big,s1}, bin1={s2}
864+ const keychains = { big : 'x' . repeat ( 497 ) , s1 : 'y' . repeat ( 98 ) , s2 : 'z' . repeat ( 98 ) } ;
865+ const bins = ffd ( keychains , { } , 600 ) ;
866+ bins . should . have . length ( 2 ) ;
867+ // 'big' should share its bin with one small item (FFD packs the largest first)
868+ const binWithBig = bins . find ( ( b ) => 'big' in b . v1Batch ) ! ;
869+ Object . keys ( binWithBig . v1Batch ) . should . have . length ( 2 ) ;
870+ } ) ;
871+ } ) ;
872+
873+ describe ( 'processKeychainPasswordUpdatesInBatches' , function ( ) {
874+ const ROOT = 'https://app.example.local' ;
875+ let bitgo : BitGoAPI ;
876+
877+ beforeEach ( function ( ) {
878+ const strategy : IHmacAuthStrategy = {
879+ calculateRequestHeaders : sinon . stub ( ) . resolves ( { hmac : 'hmac' , timestamp : Date . now ( ) , tokenHash : 'hash' } ) ,
880+ verifyResponse : sinon . stub ( ) . resolves ( {
881+ isValid : true ,
882+ expectedHmac : 'hmac' ,
883+ signatureSubject : '' ,
884+ isInResponseValidityWindow : true ,
885+ verificationTime : Date . now ( ) ,
886+ } ) ,
887+ calculateHMAC : sinon . stub ( ) . resolves ( 'hashed-pw' ) ,
888+ setToken : sinon . stub ( ) . resolves ( ) ,
889+ clearToken : sinon . stub ( ) . resolves ( ) ,
890+ } ;
891+ bitgo = new BitGoAPI ( { env : 'custom' , customRootURI : ROOT , hmacAuthStrategy : strategy } ) ;
892+ } ) ;
893+
894+ afterEach ( function ( ) {
895+ nock . cleanAll ( ) ;
896+ sinon . restore ( ) ;
897+ } ) ;
898+
899+ async function runBatches (
900+ keychains : Record < string , string > ,
901+ v2Keychains : Record < string , string > ,
902+ maxBatchSizeKB : number ,
903+ maxRetries = 3
904+ ) : Promise < void > {
905+ return ( bitgo as any ) . processKeychainPasswordUpdatesInBatches ( keychains , v2Keychains , maxBatchSizeKB , maxRetries ) ;
906+ }
907+
908+ it ( 'makes a single PUT request for a small payload' , async function ( ) {
909+ const scope = nock ( ROOT ) . put ( '/api/v2/user/keychains' ) . reply ( 200 , { } ) ;
910+ await runBatches ( { k1 : 'small' } , { } , 1024 ) ;
911+ scope . isDone ( ) . should . be . true ( ) ;
912+ } ) ;
913+
914+ it ( 'makes two PUT requests when the payload spans two bins' , async function ( ) {
915+ // id='k1'(2) + 600 = 602 bytes each; 2 entries exceed 1 KB limit => 2 bins
916+ const keychains = { k1 : 'x' . repeat ( 600 ) , k2 : 'y' . repeat ( 600 ) } ;
917+ const scope = nock ( ROOT ) . put ( '/api/v2/user/keychains' ) . twice ( ) . reply ( 200 , { } ) ;
918+ await runBatches ( keychains , { } , 1 ) ;
919+ scope . isDone ( ) . should . be . true ( ) ;
920+ } ) ;
921+
922+ it ( 'retries a failed batch and succeeds on the second attempt' , async function ( ) {
923+ nock ( ROOT ) . put ( '/api/v2/user/keychains' ) . reply ( 500 , { error : 'internal error' } ) ;
924+ nock ( ROOT ) . put ( '/api/v2/user/keychains' ) . reply ( 200 , { } ) ;
925+ // Should not throw
926+ await runBatches ( { k1 : 'value' } , { } , 1024 , 3 ) ;
927+ } ) ;
928+
929+ it ( 'throws after exhausting all retries on persistent HTTP errors' , async function ( ) {
930+ nock ( ROOT ) . put ( '/api/v2/user/keychains' ) . times ( 3 ) . reply ( 500 , { error : 'internal error' } ) ;
931+ let thrownError : Error | undefined ;
932+ try {
933+ await runBatches ( { k1 : 'value' } , { } , 1024 , 3 ) ;
934+ } catch ( e ) {
935+ thrownError = e ;
936+ }
937+ thrownError ! . message . should . match ( / f a i l e d a f t e r 3 r e t r i e s / ) ;
938+ } ) ;
939+
940+ it ( 'throws after exhausting all retries when the server reports failed keychains' , async function ( ) {
941+ nock ( ROOT )
942+ . put ( '/api/v2/user/keychains' )
943+ . times ( 3 )
944+ . reply ( 200 , { failed : { v1 : { k1 : 'encryption error' } } } ) ;
945+ let thrownError : Error | undefined ;
946+ try {
947+ await runBatches ( { k1 : 'value' } , { } , 1024 , 3 ) ;
948+ } catch ( e ) {
949+ thrownError = e ;
950+ }
951+ thrownError ! . message . should . match ( / h a d f a i l e d k e y c h a i n s / ) ;
952+ } ) ;
953+ } ) ;
954+
955+ describe ( 'changePassword batching flow' , function ( ) {
956+ const ROOT = 'https://app.example.local' ;
957+ let bitgo : BitGoAPI ;
958+ let sandbox : sinon . SinonSandbox ;
959+
960+ beforeEach ( function ( ) {
961+ sandbox = sinon . createSandbox ( ) ;
962+
963+ const strategy : IHmacAuthStrategy = {
964+ calculateRequestHeaders : sandbox . stub ( ) . resolves ( { hmac : 'hmac' , timestamp : Date . now ( ) , tokenHash : 'hash' } ) ,
965+ verifyResponse : sandbox . stub ( ) . resolves ( {
966+ isValid : true ,
967+ expectedHmac : 'hmac' ,
968+ signatureSubject : '' ,
969+ isInResponseValidityWindow : true ,
970+ verificationTime : Date . now ( ) ,
971+ } ) ,
972+ calculateHMAC : sandbox . stub ( ) . resolves ( 'hashed-pw' ) ,
973+ setToken : sandbox . stub ( ) . resolves ( ) ,
974+ clearToken : sandbox . stub ( ) . resolves ( ) ,
975+ } ;
976+
977+ bitgo = new BitGoAPI ( { env : 'custom' , customRootURI : ROOT , hmacAuthStrategy : strategy } ) ;
978+ ( bitgo as any ) . _user = { username : 'test@bitgo.com' } ;
979+
980+ sandbox . stub ( bitgo , 'verifyPassword' ) . resolves ( true ) ;
981+
982+ sandbox . stub ( bitgo , 'keychains' ) . returns ( {
983+ updatePassword : sandbox . stub ( ) . resolves ( { keychains : { k1 : 'v1enc' } , version : 25 } ) ,
984+ } as any ) ;
985+
986+ sandbox . stub ( bitgo , 'coin' ) . returns ( {
987+ keychains : ( ) => ( {
988+ updatePassword : sandbox . stub ( ) . resolves ( { v2k1 : 'v2enc' } ) ,
989+ } ) ,
990+ } as any ) ;
991+ } ) ;
992+
993+ afterEach ( function ( ) {
994+ nock . cleanAll ( ) ;
995+ sandbox . restore ( ) ;
996+ } ) ;
997+
998+ it ( 'calls PUT /user/keychains and POST /user/changepassword without keychains when batching is enabled' , async function ( ) {
999+ nock ( ROOT )
1000+ . get ( '/api/v2/user/checkBatchingPasswordFlow' )
1001+ . query ( true )
1002+ . reply ( 200 , { isBatchingFlowEnabled : true , maxBatchSizeKB : 900 } ) ;
1003+
1004+ const batchPutScope = nock ( ROOT ) . put ( '/api/v2/user/keychains' ) . reply ( 200 , { } ) ;
1005+
1006+ const changePassScope = nock ( ROOT )
1007+ . post ( '/api/v1/user/changepassword' , ( body : any ) => ! body . keychains && ! body . v2_keychains )
1008+ . reply ( 200 , { } ) ;
1009+
1010+ await bitgo . changePassword ( { oldPassword : 'oldpw' , newPassword : 'newpw' } ) ;
1011+
1012+ batchPutScope . isDone ( ) . should . be . true ( ) ;
1013+ changePassScope . isDone ( ) . should . be . true ( ) ;
1014+ } ) ;
1015+
1016+ it ( 'sends keychains in the POST body (legacy flow) when the server disables batching' , async function ( ) {
1017+ nock ( ROOT ) . get ( '/api/v2/user/checkBatchingPasswordFlow' ) . query ( true ) . reply ( 200 , { isBatchingFlowEnabled : false } ) ;
1018+
1019+ const legacyScope = nock ( ROOT )
1020+ . post ( '/api/v1/user/changepassword' , ( body : any ) => ! ! body . keychains && ! ! body . v2_keychains )
1021+ . reply ( 200 , { } ) ;
1022+
1023+ await bitgo . changePassword ( { oldPassword : 'oldpw' , newPassword : 'newpw' } ) ;
1024+
1025+ legacyScope . isDone ( ) . should . be . true ( ) ;
1026+ } ) ;
1027+
1028+ it ( 'falls back to legacy flow when the batching check request fails' , async function ( ) {
1029+ nock ( ROOT ) . get ( '/api/v2/user/checkBatchingPasswordFlow' ) . query ( true ) . reply ( 503 , { error : 'service unavailable' } ) ;
1030+
1031+ const legacyScope = nock ( ROOT )
1032+ . post ( '/api/v1/user/changepassword' , ( body : any ) => ! ! body . keychains && ! ! body . v2_keychains )
1033+ . reply ( 200 , { } ) ;
1034+
1035+ await bitgo . changePassword ( { oldPassword : 'oldpw' , newPassword : 'newpw' } ) ;
1036+
1037+ legacyScope . isDone ( ) . should . be . true ( ) ;
1038+ } ) ;
1039+ } ) ;
7971040} ) ;
0 commit comments