1212
1313/* eslint-env mocha */
1414
15+ import { EventEmitter } from 'events' ;
1516import os from 'os' ;
1617import path from 'path' ;
1718import { expect , use } from 'chai' ;
@@ -97,25 +98,50 @@ function getGitArgsStr(call) {
9798describe ( 'CloudManagerClient' , ( ) => {
9899 let CloudManagerClient ;
99100 const execFileSyncStub = sinon . stub ( ) ;
101+ const createWriteStreamStub = sinon . stub ( ) ;
100102 const existsSyncStub = sinon . stub ( ) ;
103+ const lstatSyncStub = sinon . stub ( ) ;
101104 const mkdtempSyncStub = sinon . stub ( ) ;
102105 const readdirSyncStub = sinon . stub ( ) ;
103106 const readFileSyncStub = sinon . stub ( ) . returns ( Buffer . from ( 'zip-content' ) ) ;
104107 const readlinkSyncStub = sinon . stub ( ) ;
105108 const rmSyncStub = sinon . stub ( ) ;
106109 const statfsSyncStub = sinon . stub ( ) ;
107110 const writeSyncStub = sinon . stub ( ) ;
108- const archiveFolderStub = sinon . stub ( ) . resolves ( ) ;
109111 const extractStub = sinon . stub ( ) . resolves ( ) ;
110112
113+ // Mock yazl ZipFile — records all entries and simulates piping to a write stream
114+ const mockZipEntries = [ ] ;
115+ const mockYazlZipFile = {
116+ addFile : sinon . stub ( ) . callsFake ( ( filePath , metadataPath ) => {
117+ mockZipEntries . push ( { type : 'file' , filePath, metadataPath } ) ;
118+ } ) ,
119+ addBuffer : sinon . stub ( ) . callsFake ( ( buffer , metadataPath , opts ) => {
120+ mockZipEntries . push ( {
121+ type : 'buffer' , buffer, metadataPath, opts,
122+ } ) ;
123+ } ) ,
124+ end : sinon . stub ( ) ,
125+ outputStream : {
126+ on : sinon . stub ( ) . returnsThis ( ) ,
127+ pipe : sinon . stub ( ) . callsFake ( ( writable ) => {
128+ // Simulate async close after pipe
129+ process . nextTick ( ( ) => writable . emit ( 'close' ) ) ;
130+ } ) ,
131+ } ,
132+ } ;
133+ const YazlMock = { ZipFile : sinon . stub ( ) . returns ( mockYazlZipFile ) } ;
134+
111135 // esmock's initial module resolution can exceed mocha's default 2s timeout
112136 // eslint-disable-next-line prefer-arrow-callback
113137 before ( async function ( ) {
114138 this . timeout ( 5000 ) ;
115139 const mod = await esmock ( '../src/index.js' , {
116140 child_process : { execFileSync : execFileSyncStub } ,
117141 fs : {
142+ createWriteStream : createWriteStreamStub ,
118143 existsSync : existsSyncStub ,
144+ lstatSync : lstatSyncStub ,
119145 mkdtempSync : mkdtempSyncStub ,
120146 readdirSync : readdirSyncStub ,
121147 readFileSync : readFileSyncStub ,
@@ -124,7 +150,8 @@ describe('CloudManagerClient', () => {
124150 statfsSync : statfsSyncStub ,
125151 writeFileSync : writeSyncStub ,
126152 } ,
127- 'zip-lib' : { archiveFolder : archiveFolderStub , extract : extractStub } ,
153+ 'zip-lib' : { extract : extractStub } ,
154+ yazl : YazlMock ,
128155 } , {
129156 '@adobe/spacecat-shared-ims-client' : {
130157 ImsClient : { createFrom : createFromStub } ,
@@ -136,8 +163,16 @@ describe('CloudManagerClient', () => {
136163 beforeEach ( ( ) => {
137164 execFileSyncStub . reset ( ) ;
138165 execFileSyncStub . returns ( '' ) ;
166+ createWriteStreamStub . reset ( ) ;
167+ createWriteStreamStub . callsFake ( ( ) => {
168+ const emitter = new EventEmitter ( ) ;
169+ emitter . write = sinon . stub ( ) ;
170+ emitter . end = sinon . stub ( ) ;
171+ return emitter ;
172+ } ) ;
139173 existsSyncStub . reset ( ) ;
140174 existsSyncStub . returns ( false ) ;
175+ lstatSyncStub . reset ( ) ;
141176 mkdtempSyncStub . reset ( ) ;
142177 mkdtempSyncStub . callsFake ( ( prefix ) => `${ prefix } XXXXXX` ) ;
143178 readdirSyncStub . reset ( ) ;
@@ -149,10 +184,21 @@ describe('CloudManagerClient', () => {
149184 statfsSyncStub . reset ( ) ;
150185 statfsSyncStub . returns ( { bsize : 4096 , blocks : 131072 , bfree : 65536 } ) ;
151186 writeSyncStub . reset ( ) ;
152- archiveFolderStub . reset ( ) ;
153- archiveFolderStub . resolves ( ) ;
154187 extractStub . reset ( ) ;
155188 extractStub . resolves ( ) ;
189+ // Reset yazl mocks
190+ mockZipEntries . length = 0 ;
191+ YazlMock . ZipFile . reset ( ) ;
192+ YazlMock . ZipFile . returns ( mockYazlZipFile ) ;
193+ mockYazlZipFile . addFile . reset ( ) ;
194+ mockYazlZipFile . addBuffer . reset ( ) ;
195+ mockYazlZipFile . end . reset ( ) ;
196+ mockYazlZipFile . outputStream . on . reset ( ) ;
197+ mockYazlZipFile . outputStream . on . returnsThis ( ) ;
198+ mockYazlZipFile . outputStream . pipe . reset ( ) ;
199+ mockYazlZipFile . outputStream . pipe . callsFake ( ( writable ) => {
200+ process . nextTick ( ( ) => writable . emit ( 'close' ) ) ;
201+ } ) ;
156202 createFromStub . reset ( ) ;
157203 createFromStub . returns ( mockImsClient ) ;
158204 mockImsClient . getServiceAccessToken . reset ( ) ;
@@ -878,10 +924,9 @@ describe('CloudManagerClient', () => {
878924 expect ( mkdtempSyncStub ) . to . have . been . calledOnce ;
879925 expect ( mkdtempSyncStub . firstCall . args [ 0 ] ) . to . match ( / c m - z i p - $ / ) ;
880926
881- // Should archive the folder with followSymlinks: false
882- expect ( archiveFolderStub ) . to . have . been . calledOnce ;
883- expect ( archiveFolderStub . firstCall . args [ 0 ] ) . to . equal ( clonePath ) ;
884- expect ( archiveFolderStub . firstCall . args [ 2 ] ) . to . deep . equal ( { followSymlinks : false } ) ;
927+ // Should use yazl to create the zip
928+ expect ( YazlMock . ZipFile ) . to . have . been . calledOnce ;
929+ expect ( mockYazlZipFile . end ) . to . have . been . calledOnce ;
885930
886931 // Should read the zip file into a buffer
887932 expect ( readFileSyncStub ) . to . have . been . calledOnce ;
@@ -907,23 +952,133 @@ describe('CloudManagerClient', () => {
907952 await expect ( client . zipRepository ( clonePath ) )
908953 . to . be . rejectedWith ( 'Symlink escapes repository root: evil-link -> /etc/passwd' ) ;
909954
910- // archiveFolder should never be called
911- expect ( archiveFolderStub ) . to . not . have . been . called ;
955+ // yazl should never be used
956+ expect ( YazlMock . ZipFile ) . to . not . have . been . called ;
912957
913958 // Should still clean up the temp zip directory
914959 expect ( rmSyncStub ) . to . have . been . calledOnce ;
915960 expect ( rmSyncStub . firstCall . args [ 0 ] ) . to . match ( / c m - z i p - / ) ;
916961 } ) ;
917962
918- it ( 'throws when archiveFolder fails and cleans up temp dir' , async ( ) => {
963+ it ( 'preserves broken symlinks as-is in the zip' , async ( ) => {
964+ const clonePath = '/tmp/cm-repo-zip-test' ;
965+ existsSyncStub . withArgs ( clonePath ) . returns ( true ) ;
966+
967+ const enabledFarmsPath = path . join ( clonePath , 'dispatcher' , 'enabled_farms' ) ;
968+ const brokenLinkPath = path . join ( enabledFarmsPath , 'broken.farm' ) ;
969+ const brokenTarget = '../available_farms/missing.farm' ;
970+ const resolvedTarget = path . resolve ( enabledFarmsPath , brokenTarget ) ;
971+ const symlinkMtime = new Date ( '2025-01-01' ) ;
972+ const symlinkMode = 0o120777 ;
973+
974+ // Root dir has a 'dispatcher' directory
975+ readdirSyncStub . withArgs ( clonePath , { withFileTypes : true } ) . returns ( [ {
976+ name : 'dispatcher' ,
977+ isSymbolicLink : ( ) => false ,
978+ isDirectory : ( ) => true ,
979+ } ] ) ;
980+ // dispatcher has 'enabled_farms' directory
981+ readdirSyncStub . withArgs ( path . join ( clonePath , 'dispatcher' ) , { withFileTypes : true } ) . returns ( [ {
982+ name : 'enabled_farms' ,
983+ isSymbolicLink : ( ) => false ,
984+ isDirectory : ( ) => true ,
985+ } ] ) ;
986+ // enabled_farms has a broken symlink
987+ readdirSyncStub . withArgs ( enabledFarmsPath , { withFileTypes : true } ) . returns ( [ {
988+ name : 'broken.farm' ,
989+ isSymbolicLink : ( ) => true ,
990+ isDirectory : ( ) => false ,
991+ } ] ) ;
992+ readlinkSyncStub . withArgs ( brokenLinkPath ) . returns ( brokenTarget ) ;
993+ lstatSyncStub . withArgs ( brokenLinkPath ) . returns ( { mtime : symlinkMtime , mode : symlinkMode } ) ;
994+ // Target does not exist — broken symlink
995+ existsSyncStub . withArgs ( resolvedTarget ) . returns ( false ) ;
996+
997+ const ctx = createContext ( ) ;
998+ const client = CloudManagerClient . createFrom ( ctx ) ;
999+ const result = await client . zipRepository ( clonePath ) ;
1000+
1001+ expect ( Buffer . isBuffer ( result ) ) . to . be . true ;
1002+
1003+ // Should log a warning about the broken symlink
1004+ expect ( ctx . log . warn ) . to . have . been . calledWithMatch ( / B r o k e n s y m l i n k .* b r o k e n \. f a r m .* m i s s i n g \. f a r m / ) ;
1005+
1006+ // Should add the symlink via addBuffer with symlink mode bits preserved
1007+ expect ( mockYazlZipFile . addBuffer ) . to . have . been . calledOnce ;
1008+ const [ buf , metadataPath , opts ] = mockYazlZipFile . addBuffer . firstCall . args ;
1009+ expect ( buf . toString ( ) ) . to . equal ( brokenTarget ) ;
1010+ expect ( metadataPath ) . to . equal ( path . relative ( clonePath , brokenLinkPath ) ) ;
1011+ expect ( opts . mode ) . to . equal ( symlinkMode ) ;
1012+ expect ( opts . mtime ) . to . equal ( symlinkMtime ) ;
1013+ } ) ;
1014+
1015+ it ( 'adds regular files via yazl addFile' , async ( ) => {
9191016 const clonePath = '/tmp/cm-repo-zip-test' ;
9201017 existsSyncStub . withArgs ( clonePath ) . returns ( true ) ;
921- archiveFolderStub . rejects ( new Error ( 'failed to read directory' ) ) ;
1018+
1019+ readdirSyncStub . withArgs ( clonePath , { withFileTypes : true } ) . returns ( [ {
1020+ name : 'index.html' ,
1021+ isSymbolicLink : ( ) => false ,
1022+ isDirectory : ( ) => false ,
1023+ } ] ) ;
1024+
1025+ const client = CloudManagerClient . createFrom ( createContext ( ) ) ;
1026+ await client . zipRepository ( clonePath ) ;
1027+
1028+ expect ( mockYazlZipFile . addFile ) . to . have . been . calledOnce ;
1029+ expect ( mockYazlZipFile . addFile . firstCall . args [ 0 ] ) . to . equal ( path . join ( clonePath , 'index.html' ) ) ;
1030+ expect ( mockYazlZipFile . addFile . firstCall . args [ 1 ] ) . to . equal ( 'index.html' ) ;
1031+ } ) ;
1032+
1033+ it ( 'adds valid symlinks via addBuffer with lstat mode' , async ( ) => {
1034+ const clonePath = '/tmp/cm-repo-zip-test' ;
1035+ existsSyncStub . withArgs ( clonePath ) . returns ( true ) ;
1036+
1037+ const subDir = path . join ( clonePath , 'enabled' ) ;
1038+ const linkPath = path . join ( subDir , 'link.farm' ) ;
1039+ const linkTarget = '../available/target.farm' ;
1040+ const resolvedTarget = path . resolve ( subDir , linkTarget ) ;
1041+
1042+ readdirSyncStub . withArgs ( clonePath , { withFileTypes : true } ) . returns ( [ {
1043+ name : 'enabled' ,
1044+ isSymbolicLink : ( ) => false ,
1045+ isDirectory : ( ) => true ,
1046+ } ] ) ;
1047+ readdirSyncStub . withArgs ( subDir , { withFileTypes : true } ) . returns ( [ {
1048+ name : 'link.farm' ,
1049+ isSymbolicLink : ( ) => true ,
1050+ isDirectory : ( ) => false ,
1051+ } ] ) ;
1052+ readlinkSyncStub . withArgs ( linkPath ) . returns ( linkTarget ) ;
1053+ lstatSyncStub . withArgs ( linkPath ) . returns ( { mtime : new Date ( ) , mode : 0o120777 } ) ;
1054+ existsSyncStub . withArgs ( resolvedTarget ) . returns ( true ) ;
1055+
1056+ const client = CloudManagerClient . createFrom ( createContext ( ) ) ;
1057+ await client . zipRepository ( clonePath ) ;
1058+
1059+ // Symlink stored as buffer with link target content
1060+ expect ( mockYazlZipFile . addBuffer ) . to . have . been . calledOnce ;
1061+ expect ( mockYazlZipFile . addBuffer . firstCall . args [ 0 ] . toString ( ) ) . to . equal ( linkTarget ) ;
1062+ } ) ;
1063+
1064+ it ( 'throws when yazl zip fails and cleans up temp dir' , async ( ) => {
1065+ const clonePath = '/tmp/cm-repo-zip-test' ;
1066+ existsSyncStub . withArgs ( clonePath ) . returns ( true ) ;
1067+
1068+ // Make the output stream emit an error
1069+ mockYazlZipFile . outputStream . pipe . callsFake ( ( _ ) => {
1070+ process . nextTick ( ( ) => {
1071+ // Emit error on the outputStream error handler
1072+ const errorHandler = mockYazlZipFile . outputStream . on . getCalls ( )
1073+ . find ( ( c ) => c . args [ 0 ] === 'error' ) ;
1074+ if ( errorHandler ) errorHandler . args [ 1 ] ( new Error ( 'write failed' ) ) ;
1075+ } ) ;
1076+ } ) ;
9221077
9231078 const client = CloudManagerClient . createFrom ( createContext ( ) ) ;
9241079
9251080 await expect ( client . zipRepository ( clonePath ) )
926- . to . be . rejectedWith ( 'Failed to zip repository: failed to read directory ' ) ;
1081+ . to . be . rejectedWith ( 'Failed to zip repository: write failed ' ) ;
9271082
9281083 // Should still clean up the temp zip directory
9291084 expect ( rmSyncStub ) . to . have . been . calledOnce ;
0 commit comments