11import { describe , it , expect , beforeEach , afterEach , vi } from 'vitest' ;
22import * as fsPromises from 'fs/promises' ;
33import * as path from 'path' ;
4+ import * as fs from 'fs' ; // Import fs for PathLike type
45import { McpError , ErrorCode } from '@modelcontextprotocol/sdk/types.js' ;
56import { createTemporaryFilesystem , cleanupTemporaryFilesystem } from '../testUtils.js' ;
67
@@ -12,6 +13,33 @@ vi.mock('../../src/utils/pathUtils.js', () => ({
1213 resolvePath : mockResolvePath ,
1314} ) ) ;
1415
16+ // Mock 'fs' module using doMock BEFORE importing the handler
17+ const mockCp = vi . fn ( ) ;
18+ const mockCopyFile = vi . fn ( ) ; // For fallback testing if needed later
19+ vi . doMock ( 'fs' , async ( importOriginal ) => {
20+ const actualFs = await importOriginal < typeof import ( 'fs' ) > ( ) ;
21+ const actualFsPromises = actualFs . promises ;
22+
23+ // Set default implementations to call the actual functions
24+ mockCp . mockImplementation ( actualFsPromises . cp ) ;
25+ mockCopyFile . mockImplementation ( actualFsPromises . copyFile ) ;
26+
27+ return {
28+ ...actualFs ,
29+ promises : {
30+ ...actualFsPromises ,
31+ cp : mockCp ,
32+ copyFile : mockCopyFile , // Include copyFile for potential fallback tests
33+ // Add other defaults if needed
34+ stat : vi . fn ( ) . mockImplementation ( actualFsPromises . stat ) ,
35+ access : vi . fn ( ) . mockImplementation ( actualFsPromises . access ) ,
36+ readFile : vi . fn ( ) . mockImplementation ( actualFsPromises . readFile ) ,
37+ writeFile : vi . fn ( ) . mockImplementation ( actualFsPromises . writeFile ) ,
38+ mkdir : vi . fn ( ) . mockImplementation ( actualFsPromises . mkdir ) ,
39+ } ,
40+ } ;
41+ } ) ;
42+
1543// Import the handler AFTER the mock
1644const { copyItemsToolDefinition } = await import ( '../../src/handlers/copyItems.js' ) ;
1745
@@ -236,4 +264,171 @@ describe('handleCopyItems Integration Tests', () => {
236264
237265
238266
267+
268+ it ( 'should return error when attempting to copy the project root' , async ( ) => {
269+ // Mock resolvePath to return the mocked project root for the source
270+ mockResolvePath . mockImplementation ( ( relativePath : string ) : string => {
271+ if ( relativePath === 'try_root_source' ) {
272+ return 'mocked/project/root' ; // Return the mocked root for source
273+ }
274+ // Default behavior for other paths (including destination)
275+ const absolutePath = path . resolve ( tempRootDir , relativePath ) ;
276+ if ( ! absolutePath . startsWith ( tempRootDir ) ) {
277+ throw new McpError ( ErrorCode . InvalidRequest , `Mocked Path traversal detected for ${ relativePath } ` ) ;
278+ }
279+ return absolutePath ;
280+ } ) ;
281+
282+ const request = { operations : [ { source : 'try_root_source' , destination : 'some_dest' } ] } ;
283+ const rawResult = await copyItemsToolDefinition . handler ( request ) ;
284+ const result = JSON . parse ( rawResult . content [ 0 ] . text ) ;
285+
286+ expect ( result ) . toHaveLength ( 1 ) ;
287+ expect ( result [ 0 ] . success ) . toBe ( false ) ;
288+ expect ( result [ 0 ] . error ) . toMatch ( / C o p y i n g t h e p r o j e c t r o o t i s n o t a l l o w e d / ) ;
289+ } ) ;
290+
291+ describe . skip ( 'fs.cp Fallback Tests (Node < 16.7)' , ( ) => {
292+ let originalCp : any ;
293+
294+ beforeEach ( ( ) => {
295+ // Store original and remove fs.cp
296+ originalCp = fsPromises . cp ;
297+ ( fsPromises as any ) . cp = undefined ;
298+ } ) ;
299+
300+ afterEach ( ( ) => {
301+ // Restore original fs.cp
302+ ( fsPromises as any ) . cp = originalCp ;
303+ vi . restoreAllMocks ( ) ; // Restore any spies used within tests
304+ } ) ;
305+
306+ it ( 'should fail to copy a directory using fallback' , async ( ) => {
307+ const request = { operations : [ { source : 'dirToCopy' , destination : 'fallbackDirFail' } ] } ;
308+ const rawResult = await copyItemsToolDefinition . handler ( request ) ;
309+ const result = JSON . parse ( rawResult . content [ 0 ] . text ) ;
310+
311+ expect ( result ) . toHaveLength ( 1 ) ;
312+ expect ( result [ 0 ] . success ) . toBe ( false ) ;
313+ expect ( result [ 0 ] . error ) . toMatch ( / R e c u r s i v e d i r e c t o r y c o p y r e q u i r e s N o d e .j s 1 6 .7 + / ) ;
314+ // Verify destination was not created
315+ await expect ( fsPromises . access ( path . join ( tempRootDir , 'fallbackDirFail' ) ) ) . rejects . toThrow ( ) ;
316+ } ) ;
317+
318+ it ( 'should copy a file using fallback fs.copyFile' , async ( ) => {
319+ // Spy on copyFile to ensure it's called
320+ const copyFileSpy = vi . spyOn ( fsPromises , 'copyFile' ) ;
321+
322+ const request = { operations : [ { source : 'fileToCopy.txt' , destination : 'fallbackFileSuccess.txt' } ] } ;
323+ const rawResult = await copyItemsToolDefinition . handler ( request ) ;
324+ const result = JSON . parse ( rawResult . content [ 0 ] . text ) ;
325+
326+ expect ( result ) . toHaveLength ( 1 ) ;
327+ expect ( result [ 0 ] . success ) . toBe ( true ) ;
328+ expect ( copyFileSpy ) . toHaveBeenCalledOnce ( ) ; // Verify fs.copyFile was used
329+
330+ // Verify copy
331+ const content = await fsPromises . readFile ( path . join ( tempRootDir , 'fallbackFileSuccess.txt' ) , 'utf-8' ) ;
332+ expect ( content ) . toBe ( 'Copy me!' ) ;
333+ } ) ;
334+ } ) ;
335+
336+ it ( 'should handle permission errors during copy' , async ( ) => {
337+ const sourceFile = 'fileToCopy.txt' ;
338+ const destFile = 'perm_denied_dest.txt' ;
339+ const sourcePath = path . join ( tempRootDir , sourceFile ) ;
340+ const destPath = path . join ( tempRootDir , destFile ) ;
341+
342+ // Configure the mockCp for this specific test
343+ mockCp . mockImplementation ( async ( src : string | URL , dest : string | URL , opts ?: fs . CopyOptions ) => { // Use string | URL
344+ if ( src . toString ( ) === sourcePath && dest . toString ( ) === destPath ) {
345+ const error : NodeJS . ErrnoException = new Error ( 'Mocked EPERM during copy' ) ;
346+ error . code = 'EPERM' ;
347+ throw error ;
348+ }
349+ // Fallback to default (actual cp) if needed, though unlikely in this specific test
350+ const actualFsPromises = ( await vi . importActual < typeof import ( 'fs' ) > ( 'fs' ) ) . promises ;
351+ return actualFsPromises . cp ( src , dest , opts ) ;
352+ } ) ;
353+
354+ const request = { operations : [ { source : sourceFile , destination : destFile } ] } ;
355+ const rawResult = await copyItemsToolDefinition . handler ( request ) ;
356+ const result = JSON . parse ( rawResult . content [ 0 ] . text ) ;
357+
358+ expect ( result ) . toHaveLength ( 1 ) ;
359+ expect ( result [ 0 ] . success ) . toBe ( false ) ;
360+ // Adjust assertion to match the actual error message format from the handler
361+ expect ( result [ 0 ] . error ) . toMatch ( / P e r m i s s i o n d e n i e d c o p y i n g ' f i l e T o C o p y .t x t ' t o ' p e r m _ d e n i e d _ d e s t .t x t ' / ) ;
362+ // Check that our mock function was called with the resolved paths
363+ expect ( mockCp ) . toHaveBeenCalledWith ( sourcePath , destPath , { recursive : true , errorOnExist : false , force : true } ) ; // Match handler options
364+
365+ // vi.clearAllMocks() in afterEach handles cleanup
366+ } ) ;
367+
368+ it ( 'should handle generic errors during copy' , async ( ) => {
369+ const sourceFile = 'fileToCopy.txt' ;
370+ const destFile = 'generic_error_dest.txt' ;
371+ const sourcePath = path . join ( tempRootDir , sourceFile ) ;
372+ const destPath = path . join ( tempRootDir , destFile ) ;
373+
374+ // Configure the mockCp for this specific test
375+ mockCp . mockImplementation ( async ( src : string | URL , dest : string | URL , opts ?: fs . CopyOptions ) => { // Use string | URL
376+ if ( src . toString ( ) === sourcePath && dest . toString ( ) === destPath ) {
377+ throw new Error ( 'Mocked generic copy error' ) ;
378+ }
379+ // Fallback to default (actual cp) if needed
380+ const actualFsPromises = ( await vi . importActual < typeof import ( 'fs' ) > ( 'fs' ) ) . promises ;
381+ return actualFsPromises . cp ( src , dest , opts ) ;
382+ } ) ;
383+
384+ const request = { operations : [ { source : sourceFile , destination : destFile } ] } ;
385+ const rawResult = await copyItemsToolDefinition . handler ( request ) ;
386+ const result = JSON . parse ( rawResult . content [ 0 ] . text ) ;
387+
388+ expect ( result ) . toHaveLength ( 1 ) ;
389+ expect ( result [ 0 ] . success ) . toBe ( false ) ;
390+ expect ( result [ 0 ] . error ) . toMatch ( / F a i l e d t o c o p y i t e m : M o c k e d g e n e r i c c o p y e r r o r / ) ;
391+ // Check that our mock function was called with the resolved paths
392+ expect ( mockCp ) . toHaveBeenCalledWith ( sourcePath , destPath , { recursive : true , errorOnExist : false , force : true } ) ; // Match handler options
393+
394+ // vi.clearAllMocks() in afterEach handles cleanup
395+ } ) ;
396+
397+ it ( 'should handle unexpected errors during path resolution within the map' , async ( ) => {
398+ // Mock resolvePath to throw a generic error for a specific path *after* initial validation
399+ mockResolvePath . mockImplementation ( ( relativePath : string ) : string => {
400+ if ( relativePath === 'unexpected_resolve_error_dest' ) {
401+ throw new Error ( 'Mocked unexpected resolve error' ) ;
402+ }
403+ // Default behavior
404+ const absolutePath = path . resolve ( tempRootDir , relativePath ) ;
405+ if ( ! absolutePath . startsWith ( tempRootDir ) ) {
406+ throw new McpError ( ErrorCode . InvalidRequest , `Mocked Path traversal detected for ${ relativePath } ` ) ;
407+ }
408+ return absolutePath ;
409+ } ) ;
410+
411+ const request = { operations : [
412+ { source : 'fileToCopy.txt' , destination : 'goodDest.txt' } ,
413+ { source : 'anotherFile.txt' , destination : 'unexpected_resolve_error_dest' }
414+ ] } ;
415+ const rawResult = await copyItemsToolDefinition . handler ( request ) ;
416+ const result = JSON . parse ( rawResult . content [ 0 ] . text ) ;
417+
418+ expect ( result ) . toHaveLength ( 2 ) ;
419+
420+ const goodResult = result . find ( ( r : any ) => r . destination === 'goodDest.txt' ) ;
421+ expect ( goodResult ) . toBeDefined ( ) ;
422+ expect ( goodResult . success ) . toBe ( true ) ;
423+
424+ const errorResult = result . find ( ( r : any ) => r . destination === 'unexpected_resolve_error_dest' ) ;
425+ expect ( errorResult ) . toBeDefined ( ) ;
426+ expect ( errorResult . success ) . toBe ( false ) ;
427+ // This error is caught by the inner try/catch (lines 93-94)
428+ expect ( errorResult . error ) . toMatch ( / F a i l e d t o c o p y i t e m : M o c k e d u n e x p e c t e d r e s o l v e e r r o r / ) ;
429+
430+ // Verify the successful copy occurred
431+ await expect ( fsPromises . access ( path . join ( tempRootDir , 'goodDest.txt' ) ) ) . resolves . toBeUndefined ( ) ;
432+ } ) ;
433+
239434} ) ;
0 commit comments