Skip to content

Commit

Permalink
Merge pull request #1919 from Automattic/update/media-import-validati…
Browse files Browse the repository at this point in the history
…on-configuration

Update: import validate-files command:
- Accepted file types;
- File sizes;
- File names;
  • Loading branch information
ariskataoka authored Jul 17, 2024
2 parents fafa39a + 12d47cc commit c08b76f
Show file tree
Hide file tree
Showing 6 changed files with 524 additions and 252 deletions.
170 changes: 169 additions & 1 deletion __tests__/lib/vip-import-validate-files.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import chalk from 'chalk';
import fs from 'fs';

import {
folderStructureValidation,
isFileSanitized,
validateFiles,
logErrors,
} from '../../src/lib/vip-import-validate-files';

global.console = { log: jest.fn() };
global.console = { log: jest.fn(), error: jest.fn() };

describe( 'lib/vip-import-validate-files', () => {
describe( 'folderStructureValidation', () => {
Expand Down Expand Up @@ -46,4 +51,167 @@ describe( 'lib/vip-import-validate-files', () => {
expect( isSanitized ).toBe( true );
} );
} );

describe( 'validateFiles()', () => {
const mediaImportConfig = {
allowedFileTypes: { jpg: 'image/jpeg', png: 'image/png' },
fileSizeLimitInBytes: 5000000, // 5MB
fileNameCharCount: 255,
};

afterEach( () => {
jest.clearAllMocks();
} );

it( 'should detect invalid file types', async () => {
jest
.spyOn( fs.promises, 'stat' )
.mockResolvedValue( { isDirectory: () => false, size: 4000 } );
jest.spyOn( fs, 'statSync' ).mockReturnValue( { size: 10 } );

const result = await validateFiles( [ 'file1.txt', 'file2.exe' ], mediaImportConfig );
expect( result.errorFileTypes ).toEqual( [ 'file1.txt', 'file2.exe' ] );
} );

it( 'should detect valid file types and invalid file sizes', async () => {
jest.spyOn( fs.promises, 'stat' ).mockResolvedValue( { isDirectory: () => false } );
jest.spyOn( fs, 'statSync' ).mockReturnValue( { size: 6000000 } );

const result = await validateFiles( [ 'file1.jpg', 'file2.png' ], mediaImportConfig );
expect( result.errorFileSizes ).toEqual( [ 'file1.jpg', 'file2.png' ] );
} );

it( 'should detect valid file types and valid file sizes', async () => {
jest.spyOn( fs.promises, 'stat' ).mockResolvedValue( { isDirectory: () => false } );
jest.spyOn( fs, 'statSync' ).mockReturnValue( { size: 4000 } );

const result = await validateFiles( [ 'file1.jpg', 'file2.png' ], mediaImportConfig );
expect( result.errorFileTypes ).toEqual( [] );
expect( result.errorFileSizes ).toEqual( [] );
} );

it( 'should detect files with invalid filenames', async () => {
jest.spyOn( fs.promises, 'stat' ).mockResolvedValue( { isDirectory: () => false } );
jest.spyOn( fs, 'statSync' ).mockReturnValue( { size: 4000 } );
const result = await validateFiles(
[ 'file%20name.jpg', 'file+name.png' ],
mediaImportConfig
);
expect( result.errorFileNames ).toEqual( [ 'file%20name.jpg', 'file+name.png' ] );
} );

it( 'should detect files with filenames exceeding character count limit', async () => {
jest.spyOn( fs.promises, 'stat' ).mockResolvedValue( { isDirectory: () => false } );
jest.spyOn( fs, 'statSync' ).mockReturnValue( { size: 4000 } );

const longFileName = 'a'.repeat( 256 ) + '.jpg';
const result = await validateFiles( [ longFileName ], mediaImportConfig );
expect( result.errorFileNamesCharCount ).toEqual( [ longFileName ] );
} );

it( 'should detect intermediate images', async () => {
jest.spyOn( fs.promises, 'stat' ).mockResolvedValue( { isDirectory: () => false } );
jest.spyOn( fs, 'existsSync' ).mockReturnValue( true );
jest.spyOn( fs, 'statSync' ).mockReturnValue( { size: 4000 } );

const result = await validateFiles( [ 'image-4000x6000.jpg' ], mediaImportConfig );
expect( result.intermediateImagesTotal ).toBe( 1 );
} );
} );

describe( 'logErrors()', () => {
const mockConsoleError = jest.spyOn( console, 'error' ).mockImplementation();

afterEach( () => {
mockConsoleError.mockClear();
} );

it( 'should log correct messages for invalid_types', () => {
const errorType = 'invalid_types';
const invalidFiles = [ 'file1.txt', 'file2.jpg' ];
const limit = '';

logErrors( { errorType, invalidFiles, limit } );

invalidFiles.forEach( file => {
expect( mockConsoleError ).toHaveBeenCalledWith(
chalk.red( '✕' ),
'File extensions: Invalid file type for file: ',
chalk.cyan( file )
);
} );
} );

it( 'should log correct messages for intermediate_images', () => {
const errorType = 'intermediate_images';
const invalidFiles = [ 'image1.jpg' ];
const limit = { 'image1.jpg': 'intermediate1.jpg' };

logErrors( { errorType, invalidFiles, limit } );

invalidFiles.forEach( file => {
expect( mockConsoleError ).toHaveBeenCalledWith(
chalk.red( '✕' ),
'Intermediate images: Duplicate files found:\n' +
'Original file: ' +
chalk.blue( `${ file }\n` ) +
'Intermediate images: ' +
chalk.cyan( `${ limit[ file ] }\n` )
);
} );
} );

it( 'should log correct messages for invalid_sizes', () => {
const errorType = 'invalid_sizes';
const invalidFiles = [ 'file3.pdf' ];
const limit = 1024;

logErrors( { errorType, invalidFiles, limit } );

invalidFiles.forEach( file => {
expect( mockConsoleError ).toHaveBeenCalledWith(
chalk.red( '✕' ),
`File size cannot be more than ${ limit / 1024 / 1024 / 1024 } GB`,
chalk.cyan( file )
);
} );
} );

it( 'should log correct messages for invalid_name_character_counts', () => {
const errorType = 'invalid_name_character_counts';
const invalidFiles = [ 'longfilename.png' ];
const limit = 20;

logErrors( { errorType, invalidFiles, limit } );

invalidFiles.forEach( file => {
expect( mockConsoleError ).toHaveBeenCalledWith(
chalk.red( '✕' ),
`File name cannot have more than ${ limit } characters`,
chalk.cyan( file )
);
} );
} );

it( 'should log correct messages for invalid_names', () => {
const errorType = 'invalid_names';
const invalidFiles = [ 'invalid$file.txt' ];
const limit = '';

logErrors( { errorType, invalidFiles, limit } );

invalidFiles.forEach( file => {
expect( mockConsoleError ).toHaveBeenCalledWith(
chalk.red( '✕' ),
'Character validation: Invalid filename for file: ',
chalk.cyan( file )
);
} );
} );

it( 'should not log anything if invalidFiles array is empty', () => {
logErrors( { errorType: 'invalid_types', invalidFiles: [], limit: '' } );
expect( mockConsoleError ).not.toHaveBeenCalled();
} );
} );
} );
148 changes: 63 additions & 85 deletions src/bin/vip-import-validate-files.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,31 @@
#!/usr/bin/env node

/**
* External dependencies
*/
import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
import url from 'url';

/**
* Internal dependencies
*/
import command from '../lib/cli/command';
import { getMediaImportConfig } from '../lib/media-import/config';
import { trackEvent } from '../lib/tracker';
import {
acceptedExtensions,
findNestedDirectories,
folderStructureValidation,
isFileSanitized,
doesImageHaveExistingSource,
logErrorsForIntermediateImages,
logErrorsForInvalidFileTypes,
logErrorsForInvalidFilenames,
summaryLogs,
validateFiles,
logErrors,
ValidateFilesErrors,
} from '../lib/vip-import-validate-files';

command( { requiredArgs: 1, format: true } )
.example( 'vip import validate-files <file>', 'Run the import validation against the file' )
.example(
'vip import validate-files <folder_name>',
'Run the import validation against the folder of media files'
)
.argv( process.argv, async arg => {
await trackEvent( 'import_validate_files_command_execute' );
/**
Expand Down Expand Up @@ -64,95 +69,68 @@ command( { requiredArgs: 1, format: true } )
}

/**
* Media file extension validation
*
* Ensure that prohibited media file types are not used
* Get Media Import configuration
*/

// Collect invalid files for error logging
let intermediateImagesTotal = 0;

const errorFileTypes = [];
const errorFileNames = [];
const intermediateImages = {};

// Iterate through each file to isolate the extension name
for ( const file of files ) {
// Check if file is a directory
// eslint-disable-next-line no-await-in-loop
const stats = await fs.promises.stat( file );
const isFolder = stats.isDirectory();

const extension = path.extname( file ); // Extract the extension of the file
const ext = extension.substr( 1 ); // We only want the ext name minus the period (e.g- .jpg -> jpg)
const extLowerCase = ext.toLowerCase(); // Change any uppercase extensions to lowercase

// Check for any invalid file extensions
// Returns true if ext is valid; false if invalid
const validExtensions = acceptedExtensions.includes( extLowerCase );

// Collect files that have no extension, have invalid extensions,
// or are directories for error logging
if ( ! extension || ! validExtensions || isFolder ) {
errorFileTypes.push( file );
}

/**
* Filename validation
*
* Ensure that filenames don't contain prohibited characters
*/

// Collect files that have invalid file names for error logging
if ( isFileSanitized( file ) ) {
errorFileNames.push( file );
}

/**
* Intermediate image validation
*
* Detect any intermediate images.
*
* Intermediate images are copies of images that are resized, so you may have multiples of the same image.
* You can resize an image directly on VIP so intermediate images are not necessary.
*/
const original = doesImageHaveExistingSource( file );

// If an image is an intermediate image, increment the total number and
// populate key/value pairs of the original image and intermediate image(s)
if ( original ) {
intermediateImagesTotal++;

if ( intermediateImages[ original ] ) {
// Key: original image, value: intermediate image(s)
intermediateImages[ original ] = `${ intermediateImages[ original ] }, ${ file }`;
} else {
intermediateImages[ original ] = file;
}
}
const mediaImportConfig = await getMediaImportConfig();

if ( ! mediaImportConfig ) {
console.error(
chalk.red( '✕ Error:' ),
'Could not retrieve validation metadata. Please contact VIP Support.'
);
return;
}

/**
* Error logging
* File Validation
* Collect all errors from file validation
*/
if ( errorFileTypes.length > 0 ) {
logErrorsForInvalidFileTypes( errorFileTypes );
}
const {
intermediateImagesTotal,
errorFileTypes,
errorFileNames,
errorFileSizes,
errorFileNamesCharCount,
intermediateImages,
} = await validateFiles( files, mediaImportConfig );

if ( errorFileNames.length > 0 ) {
logErrorsForInvalidFilenames( errorFileNames );
}

if ( Object.keys( intermediateImages ).length > 0 ) {
logErrorsForIntermediateImages( intermediateImages );
}
/**
* Error logging
* Not sure if the changes made to the error logging better
*/
logErrors( {
errorType: ValidateFilesErrors.INVALID_TYPES,
invalidFiles: errorFileTypes,
limit: Object.keys( mediaImportConfig.allowedFileTypes ),
} );
logErrors( {
errorType: ValidateFilesErrors.INVALID_SIZES,
invalidFiles: errorFileSizes,
limit: mediaImportConfig.fileSizeLimitInBytes,
} );
logErrors( {
errorType: ValidateFilesErrors.INVALID_NAME_CHARACTER_COUNTS,
invalidFiles: errorFileNamesCharCount,
limit: mediaImportConfig.fileNameCharCount,
} );
logErrors( {
errorType: ValidateFilesErrors.INVALID_NAMES,
invalidFiles: errorFileNames,
} );
logErrors( {
errorType: ValidateFilesErrors.INTERMEDIATE_IMAGES,
invalidFiles: Object.keys( intermediateImages ),
invalidFilesObj: intermediateImages,
} );

// Log a summary of all errors
summaryLogs( {
folderErrorsLength: folderValidation.length,
intImagesErrorsLength: intermediateImagesTotal,
fileTypeErrorsLength: errorFileTypes.length,
fileErrorFileSizesLength: errorFileSizes.length,
filenameErrorsLength: errorFileNames.length,
fileNameCharCountErrorsLength: errorFileNamesCharCount.length,
totalFiles: files.length,
totalFolders: nestedDirectories.length,
} );
Expand Down
Loading

0 comments on commit c08b76f

Please sign in to comment.