diff --git a/README.md b/README.md index d0c73fcb..814822d3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # skyflow-js -Skyflow’s JavaScript SDK can be used to securely collect, tokenize, and reveal sensitive data in the browser without exposing your front-end infrastructure to sensitive data. +Skyflow's JavaScript SDK can be used to securely collect, tokenize, and reveal sensitive data in the browser without exposing your front-end infrastructure to sensitive data. --- @@ -2791,7 +2791,8 @@ Note: ### File upload limitations: - Only non-executable file are allowed to be uploaded. -- Files must have a maximum size of 32 MB +- Files have a default maximum size of 32 MB per file. This limit is configurable using the `maxFileSize` option. +- Up to 4 files can be uploaded at a time by default. This limit is configurable using the `maxFileCount` option. - File columns can't enable tokenization, redaction, or arrays. - Re-uploading a file overwrites previously uploaded data. - Partial uploads or resuming a previous upload isn't supported. @@ -2851,10 +2852,19 @@ element.uploadMultipleFiles(); Along with fileElementInput, you can define other options in the Options object as described below: ```js const options = { - allowedFileType: String[], // Optional, indicates the allowed file types for upload + allowedFileType: String[], // Optional. Restricts uploads to the listed file extensions (e.g. [".pdf", ".png"]). + blockEmptyFiles: Boolean, // Optional. When true, rejects files with 0 bytes. Default: false. + preserveFileName: Boolean, // Optional. When true, keeps the original filename on upload. Default: false. + maxFileSize: Number, // Optional. Maximum size in bytes for each individual file. Default: 32000000 (32 MB). + maxFileCount: Number, // Optional. Maximum number of files that can be selected at once. Must be a positive integer. Default: 4. } ``` -`allowedFileType`: An array of string value that indicates the allowedFileTypes to be uploaded. + +- `allowedFileType`: An array of strings indicating which file extensions are accepted for upload. +- `blockEmptyFiles`: When `true`, files with a size of 0 bytes are rejected. +- `preserveFileName`: When `true`, the original filename is preserved on upload. +- `maxFileSize`: Maximum allowed size **per file**, in bytes. If any file exceeds this limit, a validation error is shown with the filename. Defaults to `32000000` (32 MB). Only applies to `MULTI_FILE_INPUT` elements. +- `maxFileCount`: Maximum number of files that can be selected for a single upload. Must be a positive integer. Defaults to `4`. Only applies to `MULTI_FILE_INPUT` elements. #### File upload with options example @@ -2889,8 +2899,10 @@ const cardNumberElement = collectContainer.create({ label: 'Card Number', type: Skyflow.ElementType.CARD_NUMBER, }); -const options = { - allowedFileType: [".pdf",".png"]; +const options = { + allowedFileType: [".pdf", ".png"], + maxFileSize: 5000000, // 5 MB per file + maxFileCount: 3, // up to 3 files at once }; const fileElement = collectContainer.create({ table: 'newTable', diff --git a/package.json b/package.json index aa5019e7..bd10b4db 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "skyflow-js", "preferGlobal": true, "analyze": false, - "version": "2.7.7", + "version": "2.7.7-dev.a6816dc", "author": "Skyflow", "description": "Skyflow JavaScript SDK", "homepage": "https://github.com/skyflowapi/skyflow-js", diff --git a/samples/using-script-tag/composable-multi-file-upload.html b/samples/using-script-tag/composable-multi-file-upload.html index 537ef2e2..ec2fb583 100644 --- a/samples/using-script-tag/composable-multi-file-upload.html +++ b/samples/using-script-tag/composable-multi-file-upload.html @@ -147,6 +147,8 @@

Collect Composable Elements

const options = { allowedFileType: ["", ""], blockEmptyFiles: true, // default is false, if set to true, it will block empty files + maxFileSize: 1000000, // default is 32000000 (32MB), if set, it will block files greater than the specified size + maxFileCount: 3, // default is 5, if set, it will block if the number of files exceeds the specified number } const fileElement = collectContainer.create({ diff --git a/src/core/internal/frame-element-init.ts b/src/core/internal/frame-element-init.ts index 62eb3769..d4c3543f 100644 --- a/src/core/internal/frame-element-init.ts +++ b/src/core/internal/frame-element-init.ts @@ -520,7 +520,12 @@ export default class FrameElementInit { } const files = state.value instanceof FileList ? Array.from(state.value) : [state.value]; - this.validateFiles(files, state, fileElement); + try { + this.validateFiles(files, state, fileElement); + } catch (err: any) { + rootReject({ errorResponse: [{ error: err?.error || err?.errors?.[0] || err }] }); + return; + } const uploadFile = (file: File, skyflowID?: string) => { const formData = new FormData(); @@ -624,14 +629,19 @@ export default class FrameElementInit { }); private validateFiles = (files: File[], state: any, fileElement: IFrameFormElement) => { + if (files.length > fileElement.maxFileCount) { + throw new SkyflowError( + SKYFLOW_ERROR_CODE.FILE_COUNT_EXCEEDED, + [String(fileElement.maxFileCount)], + true, + ); + } files.forEach((file) => { - // Check file validation const validatedFileState = fileValidation(file, state.isRequired, fileElement); if (!validatedFileState) { throw new SkyflowError(SKYFLOW_ERROR_CODE.INVALID_FILE_TYPE, [], true); } - // Check filename validation const isValidFileName = vaildateFileName(file.name); if (!isValidFileName) { throw new SkyflowError(SKYFLOW_ERROR_CODE.INVALID_FILE_NAME, [], true); @@ -700,8 +710,9 @@ export default class FrameElementInit { type: error?.error?.type, }, }); + } else { + rootReject(error); } - rootReject(error); }); }); diff --git a/src/core/internal/iframe-form/index.ts b/src/core/internal/iframe-form/index.ts index 1c2de7ba..889cf505 100644 --- a/src/core/internal/iframe-form/index.ts +++ b/src/core/internal/iframe-form/index.ts @@ -113,6 +113,10 @@ export default class IFrameFormElement extends EventEmitter { blockEmptyFiles: boolean = false; + maxFileSize: number = 32_000_000; + + maxFileCount: number = 4; + constructor(name: string, label: string, metaData: any, context: Context, skyflowID?: string) { super(); const frameValues = name.split(':'); @@ -510,6 +514,7 @@ export default class IFrameFormElement extends EventEmitter { validator(value: any) { let resp = true; let vaildateFileNames = true; + let fileSpecificError = ''; if (this.fieldType === ElementType.CARD_NUMBER && value) { if (this.regex) { @@ -537,16 +542,55 @@ export default class IFrameFormElement extends EventEmitter { const files = this.state.value instanceof FileList ? Array.from(this.state.value) : [this.state.value]; - for (let i = 0; i < files.length; i += 1) { - try { - resp = fileValidation(files[i], this.state.isRequired, { - allowedFileType: this.allowedFileType, - blockEmptyFiles: this.blockEmptyFiles, - }); - } catch (err) { - resp = false; + const sizeMBDisplay = `${Math.round(this.maxFileSize / 1_000_000)} MB`; + const countExceeded = files.length > this.maxFileCount; + const firstOversizedFile = files.find((f) => f.size > this.maxFileSize); + + if (countExceeded && firstOversizedFile) { + resp = false; + fileSpecificError = parameterizedString( + logs.errorLogs.FILE_COUNT_AND_SIZE_EXCEEDED, + String(this.maxFileCount), + sizeMBDisplay, + ); + } else if (countExceeded) { + resp = false; + fileSpecificError = parameterizedString( + logs.errorLogs.FILE_COUNT_EXCEEDED, + String(this.maxFileCount), + ); + } else { + const oversizedFileNames: string[] = []; + for (let i = 0; i < files.length; i += 1) { + try { + const fileValid = fileValidation(files[i], this.state.isRequired, { + allowedFileType: this.allowedFileType, + blockEmptyFiles: this.blockEmptyFiles, + maxFileSize: this.maxFileSize, + }); + if (!fileValid) resp = false; + } catch (err: any) { + resp = false; + if (files[i].size > this.maxFileSize) { + oversizedFileNames.push(files[i].name); + } + } + if (this.preserveFileName) vaildateFileNames = vaildateFileName(files[i].name); + } + if (oversizedFileNames.length > 0) { + if (files.length === 1) { + fileSpecificError = parameterizedString( + logs.errorLogs.FILE_SIZE_EXCEEDED_SINGLE, + sizeMBDisplay, + ); + } else { + fileSpecificError = parameterizedString( + logs.errorLogs.FILE_SIZE_EXCEEDED_WITH_NAME, + oversizedFileNames.join(', '), + sizeMBDisplay, + ); + } } - if (this.preserveFileName) vaildateFileNames = vaildateFileName(files[i].name); } } else { // eslint-disable-next-line no-lonely-if @@ -557,7 +601,9 @@ export default class IFrameFormElement extends EventEmitter { if (!resp || !vaildateFileNames) { this.isCustomValidationFailed = false; if (!resp) { - if (this.label) { + if (fileSpecificError) { + this.errorText = fileSpecificError; + } else if (this.label) { this.errorText = `${parameterizedString( logs.errorLogs.INVALID_COLLECT_VALUE_WITH_LABEL, this.label, diff --git a/src/core/internal/index.ts b/src/core/internal/index.ts index 22f8044e..3fda635c 100644 --- a/src/core/internal/index.ts +++ b/src/core/internal/index.ts @@ -116,6 +116,12 @@ export default class FrameElement { if (Object.prototype.hasOwnProperty.call(options, 'blockEmptyFiles')) { this.iFrameFormElement.blockEmptyFiles = options?.blockEmptyFiles; } + if (Object.prototype.hasOwnProperty.call(options, 'maxFileSize')) { + this.iFrameFormElement.maxFileSize = options?.maxFileSize; + } + if (Object.prototype.hasOwnProperty.call(options, 'maxFileCount')) { + this.iFrameFormElement.maxFileCount = options?.maxFileCount; + } } // mount element onto dom diff --git a/src/libs/element-options.ts b/src/libs/element-options.ts index df162c82..be517865 100644 --- a/src/libs/element-options.ts +++ b/src/libs/element-options.ts @@ -423,10 +423,27 @@ export const formatOptions = ( if (Object.prototype.hasOwnProperty.call(options, 'blockEmptyFiles')) { formattedOptions = { ...formattedOptions, - blockEmptyFiles: formattedOptions.blockEmptyFiles, + blockEmptyFiles: options.blockEmptyFiles, }; } } + if (elementType === ELEMENTS.MULTI_FILE_INPUT.name) { + if (Object.prototype.hasOwnProperty.call(options, 'maxFileSize')) { + if (typeof options.maxFileSize !== 'number' || options.maxFileSize <= 0) { + throw new SkyflowError(SKYFLOW_ERROR_CODE.INVALID_POSITIVE_NUMBER_OPTIONS, ['maxFileSize'], true); + } + formattedOptions = { ...formattedOptions, maxFileSize: options.maxFileSize }; + } + if (Object.prototype.hasOwnProperty.call(options, 'maxFileCount')) { + if (typeof options.maxFileCount !== 'number' || options.maxFileCount <= 0 || !Number.isInteger(options.maxFileCount)) { + throw new SkyflowError(SKYFLOW_ERROR_CODE.INVALID_POSITIVE_NUMBER_OPTIONS, ['maxFileCount'], true); + } + formattedOptions = { ...formattedOptions, maxFileCount: options.maxFileCount }; + } + } else { + delete formattedOptions?.maxFileSize; + delete formattedOptions?.maxFileCount; + } if (Object.prototype.hasOwnProperty.call(options, 'masking')) { if (!validateBooleanOptions(options.masking)) { diff --git a/src/utils/common/index.ts b/src/utils/common/index.ts index ecf72435..eb6d78b6 100644 --- a/src/utils/common/index.ts +++ b/src/utils/common/index.ts @@ -328,6 +328,8 @@ export interface CollectElementOptions { preserveFileName?: boolean, allowedFileType?: string[], blockEmptyFiles?: boolean, + maxFileSize?: number, + maxFileCount?: number, masking?: boolean, maskingChar?: string, } diff --git a/src/utils/constants.ts b/src/utils/constants.ts index be4efe30..6396792f 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -396,6 +396,22 @@ const SKYFLOW_ERROR_CODE = { code: 400, description: logs.errorLogs.NO_FILE_SELECTED, }, + FILE_COUNT_EXCEEDED: { + code: 400, + description: logs.errorLogs.FILE_COUNT_EXCEEDED, + }, + FILE_SIZE_EXCEEDED_SINGLE: { + code: 400, + description: logs.errorLogs.FILE_SIZE_EXCEEDED_SINGLE, + }, + FILE_SIZE_EXCEEDED_WITH_NAME: { + code: 400, + description: logs.errorLogs.FILE_SIZE_EXCEEDED_WITH_NAME, + }, + FILE_COUNT_AND_SIZE_EXCEEDED: { + code: 400, + description: logs.errorLogs.FILE_COUNT_AND_SIZE_EXCEEDED, + }, INVALID_TABLE_IN_UPSERT_OPTION: { code: 400, description: logs.errorLogs.INVALID_TABLE_IN_UPSERT_OPTION, @@ -552,6 +568,10 @@ const SKYFLOW_ERROR_CODE = { code: 400, description: logs.errorLogs.INVALID_BOOLEAN_OPTIONS, }, + INVALID_POSITIVE_NUMBER_OPTIONS: { + code: 400, + description: logs.errorLogs.INVALID_POSITIVE_NUMBER_OPTIONS, + }, INVALID_MASKING_CHARACTER: { code: 400, description: logs.errorLogs.INVALID_MASKING_CHARACTER, diff --git a/src/utils/helpers/index.ts b/src/utils/helpers/index.ts index 58fa0a43..9d5b4db5 100644 --- a/src/utils/helpers/index.ts +++ b/src/utils/helpers/index.ts @@ -211,7 +211,10 @@ export const fileValidation = (value, required: Boolean = false, fileElement) => } } } - if (value.size > 32000000) { + const sizeLimit = (Object.prototype.hasOwnProperty.call(fileElement, 'maxFileSize') && typeof fileElement.maxFileSize === 'number') + ? fileElement.maxFileSize + : 32_000_000; + if (value.size > sizeLimit) { throw new SkyflowError(SKYFLOW_ERROR_CODE.INVALID_FILE_SIZE, [], true); } if (Object.prototype.hasOwnProperty.call(fileElement, 'blockEmptyFiles') && fileElement.blockEmptyFiles) { diff --git a/src/utils/logs.ts b/src/utils/logs.ts index 1d7be2aa..57583319 100644 --- a/src/utils/logs.ts +++ b/src/utils/logs.ts @@ -269,6 +269,10 @@ const logs = { INVALID_FILE_TYPE: 'Invalid File Type.', INVALID_FILE_SIZE: 'Invalid File Size', NO_FILE_SELECTED: 'No File Selected', + FILE_COUNT_EXCEEDED: 'You can upload up to %s1 files. Remove file(s) to add more', + FILE_SIZE_EXCEEDED_SINGLE: 'File exceeds the %s1 size limit. Choose a smaller file', + FILE_SIZE_EXCEEDED_WITH_NAME: '%s1 exceeds the %s2 size limit. Remove it or choose a smaller file', + FILE_COUNT_AND_SIZE_EXCEEDED: 'You can upload up to %s1 files, each under %s2', INVALID_UPSERT_OPTION_TYPE: 'Validation error. Invalid \'upsert\' key in insert options. Specify a value of type array instead', EMPTY_UPSERT_OPTIONS_ARRAY: @@ -317,6 +321,7 @@ const logs = { INVALID_COMPOSABLE_CONTAINER_OPTIONS: 'Mount failed. Invalid options object. Specify a valid options object.', COMPOSABLE_CONTAINER_NOT_MOUNTED: 'Mount elements first. Make sure all elements are mounted before calling \'collect\' on the container.', INVALID_BOOLEAN_OPTIONS: 'Validation error. Invalid %s1 found in collect options. Specify a value of type boolean instead.', + INVALID_POSITIVE_NUMBER_OPTIONS: 'Validation error. Invalid %s1 found in collect options. Specify a positive number instead.', INVALID_MASKING_CHARACTER: 'Validation error. Invalid masking character. Specify a valid masking character. ', INVALID_INPUT_OPTIONS_FORMAT: 'Mount failed. Format must be a non-empty string. Specify a valid format.', INVALID_INPUT_OPTIONS_TRANSLATION: 'Mount failed. Translation must be a non-empty object. Specify a valid translation.', diff --git a/tests/core/internal/frame-element-init.additional.test.js b/tests/core/internal/frame-element-init.additional.test.js index 58b4636b..a2b1f89a 100644 --- a/tests/core/internal/frame-element-init.additional.test.js +++ b/tests/core/internal/frame-element-init.additional.test.js @@ -39,6 +39,8 @@ jest.mock('../../../src/core-utils/collect', () => { import FrameElementInit from '../../../src/core/internal/frame-element-init'; import SkyflowError from '../../../src/libs/skyflow-error'; import { ELEMENTS } from '../../../src/core/constants'; +import logs from '../../../src/utils/logs'; +import { parameterizedString } from '../../../src/utils/logs-helper'; import * as helpers from '../../../src/utils/helpers'; import Client, { mockClientRequest } from '../../../src/client'; import { ELEMENT_EVENTS_TO_IFRAME, COLLECT_TYPES } from '../../../src/core/constants'; @@ -61,8 +63,24 @@ const makeFile = (name = 'test.txt', size = 10, type = 'text/plain') => { return new File([content], name, { type }); }; +// Builds a FileList-like object that passes `instanceof FileList` (DataTransfer unavailable in jsdom) +const makeFileList = (...files) => { + const arr = [...files]; + Object.defineProperty(arr, 'item', { value: (i) => arr[i] }); + if (typeof FileList !== 'undefined') Object.setPrototypeOf(arr, FileList.prototype); + return arr; +}; + // Minimal stub for an iframe form element expected by FrameElementInit internals -const makeFileElement = ({ multiple = false, files, name = 'upload', tableName = 'files_table', preserveFileName = true }) => { +const makeFileElement = ({ + multiple = false, + files, + name = 'upload', + tableName = 'files_table', + preserveFileName = true, + maxFileCount = 4, + maxFileSize = 32_000_000, +}) => { const value = multiple ? files : files[0]; return { state: { @@ -73,6 +91,8 @@ const makeFileElement = ({ multiple = false, files, name = 'upload', tableName = tableName, onFocusChange: jest.fn(), preserveFileName, + maxFileCount, + maxFileSize, fieldType: multiple ? ELEMENTS.MULTI_FILE_INPUT.name : ELEMENTS.FILE_INPUT.name, iFrameName: `element:${multiple ? 'MULTI' : 'SINGLE'}_FILE_INPUT:123`, }; @@ -267,7 +287,7 @@ describe('FrameElementInit extended unit tests', () => { await expect(instance['multipleUploadFiles'](fileElement, config, { meta: 'x' })).rejects.toEqual({ error: 'No skyflow IDs returned from insert data' }); }); - test('multipleUploadFiles filename validation failure', async () => { + test('multipleUploadFiles filename validation failure returns errorResponse format', async () => { const instance = new FrameElementInit(); const files = [makeFile('bad.txt')]; const fileElement = makeFileElement({ multiple: true, files }); @@ -275,7 +295,87 @@ describe('FrameElementInit extended unit tests', () => { helpers.fileValidation = jest.fn(() => true); helpers.vaildateFileName = jest.fn(() => false); // force invalid name const config = { vaultURL: 'https://vault.url', vaultID: 'vault123', authToken: 'token123' }; - await expect(instance['multipleUploadFiles'](fileElement, config, {})).rejects.toBeTruthy(); + const err = await instance['multipleUploadFiles'](fileElement, config, {}).catch(e => e); + expect(err).toHaveProperty('errorResponse'); + expect(err.errorResponse).toHaveLength(1); + expect(err.errorResponse[0]).toHaveProperty('error'); + }); + + test('multipleUploadFiles rejects with errorResponse format when file size validation fails', async () => { + const instance = new FrameElementInit(); + const files = [makeFile('large.pdf', 6000000)]; + const fileElement = makeFileElement({ multiple: true, files, maxFileSize: 5000000 }); + fileElement.state.value = files; + instance.iframeFormList = [fileElement]; + const sizeError = new SkyflowError({ code: 400, description: 'Invalid File Size' }, [], true); + helpers.fileValidation = jest.fn(() => { throw sizeError; }); + helpers.vaildateFileName = jest.fn(() => true); + const config = { vaultURL: 'https://vault.url', vaultID: 'vault123', authToken: 'token123' }; + await expect(instance['multipleUploadFiles'](fileElement, config, undefined)) + .rejects.toEqual({ errorResponse: [{ error: { code: 400, description: 'Invalid File Size' } }] }); + }); + + test('multipleUploadFiles rejects with errorResponse format when file count exceeded', async () => { + const instance = new FrameElementInit(); + const files = [makeFile('a.txt'), makeFile('b.txt'), makeFile('c.txt')]; + const fileElement = makeFileElement({ multiple: true, files, maxFileCount: 2 }); + fileElement.state.value = makeFileList(...files); // FileList so Array.from() is used, preserving count + instance.iframeFormList = [fileElement]; + helpers.fileValidation = jest.fn(() => true); + helpers.vaildateFileName = jest.fn(() => true); + const config = { vaultURL: 'https://vault.url', vaultID: 'vault123', authToken: 'token123' }; + const err = await instance['multipleUploadFiles'](fileElement, config, undefined).catch(e => e); + expect(err).toHaveProperty('errorResponse'); + expect(err.errorResponse).toHaveLength(1); + expect(err.errorResponse[0].error).toMatchObject({ code: 400 }); + }); + + test('multipleUploadFiles rejects with { error: "No files selected" } when state.value is empty', async () => { + const instance = new FrameElementInit(); + const fileElement = makeFileElement({ multiple: true, files: [makeFile('a.txt')] }); + fileElement.state.value = ''; + const config = { vaultURL: 'https://vault.url', vaultID: 'vault123', authToken: 'token123' }; + await expect(instance['multipleUploadFiles'](fileElement, config, undefined)) + .rejects.toEqual({ error: 'No files selected' }); + }); + + test('multipleUploadFiles rejects with { error: "No files selected" } when state.value is null', async () => { + const instance = new FrameElementInit(); + const fileElement = makeFileElement({ multiple: true, files: [makeFile('a.txt')] }); + fileElement.state.value = null; + const config = { vaultURL: 'https://vault.url', vaultID: 'vault123', authToken: 'token123' }; + await expect(instance['multipleUploadFiles'](fileElement, config, undefined)) + .rejects.toEqual({ error: 'No files selected' }); + }); + + test('multipleUploadFiles errorResponse contains error when SkyflowError has .errors[] (plural)', async () => { + const instance = new FrameElementInit(); + const files = [makeFile('test.txt')]; + const fileElement = makeFileElement({ multiple: true, files }); + fileElement.state.value = files; + const pluralError = new SkyflowError({ code: 400, description: 'Multiple issues' }, [], false); + helpers.fileValidation = jest.fn(() => { throw pluralError; }); + helpers.vaildateFileName = jest.fn(() => true); + const config = { vaultURL: 'https://vault.url', vaultID: 'vault123', authToken: 'token123' }; + const err = await instance['multipleUploadFiles'](fileElement, config, undefined).catch(e => e); + expect(err).toHaveProperty('errorResponse'); + expect(err.errorResponse[0].error).toBeDefined(); + }); + + test('multipleUploadFiles rejects with errorResponse format when validation fails in metaData branch', async () => { + const instance = new FrameElementInit(); + const files = [makeFile('a.txt'), makeFile('b.txt'), makeFile('c.txt')]; + const fileElement = makeFileElement({ multiple: true, files, maxFileCount: 2 }); + fileElement.state.value = makeFileList(...files); // FileList so Array.from() is used, preserving count + instance.iframeFormList = [fileElement]; + helpers.fileValidation = jest.fn(() => true); + helpers.vaildateFileName = jest.fn(() => true); + const config = { vaultURL: 'https://vault.url', vaultID: 'vault123', authToken: 'token123' }; + const err = await instance['multipleUploadFiles'](fileElement, config, { meta: 'x' }).catch(e => e); + expect(err).toHaveProperty('errorResponse'); + expect(err.errorResponse[0].error).toMatchObject({ code: 400 }); + // insertDataCallInMultiFiles must NOT have been called since validation failed first + expect(mockClientRequest).not.toHaveBeenCalled(); }); // ===== multipleUploadFiles else branch (no metaData) coverage lines ~548-587 ===== @@ -324,6 +424,92 @@ describe('FrameElementInit extended unit tests', () => { expect(() => instance['validateFiles'](files, fileElement.state, fileElement)).toThrow(SkyflowError); }); + test('validateFiles throws FILE_COUNT_EXCEEDED when file count exceeds maxFileCount', () => { + const instance = new FrameElementInit(); + const files = [makeFile('a.txt'), makeFile('b.txt'), makeFile('c.txt')]; + const fileElement = makeFileElement({ multiple: true, files, maxFileCount: 2 }); + helpers.fileValidation = jest.fn(() => true); + helpers.vaildateFileName = jest.fn(() => true); + expect(() => instance['validateFiles'](files, fileElement.state, fileElement)).toThrow(SkyflowError); + }); + + test('validateFiles FILE_COUNT_EXCEEDED error message contains the configured maxFileCount', () => { + const instance = new FrameElementInit(); + const files = [makeFile('a.txt'), makeFile('b.txt'), makeFile('c.txt')]; + const fileElement = makeFileElement({ multiple: true, files, maxFileCount: 2 }); + helpers.fileValidation = jest.fn(() => true); + helpers.vaildateFileName = jest.fn(() => true); + let caughtError; + try { + instance['validateFiles'](files, fileElement.state, fileElement); + } catch (err) { + caughtError = err; + } + expect(caughtError).toBeInstanceOf(SkyflowError); + expect(caughtError.error.description).toBe( + parameterizedString(logs.errorLogs.FILE_COUNT_EXCEEDED, '2'), + ); + }); + + test('validateFiles FILE_COUNT_EXCEEDED message reflects a different configured maxFileCount', () => { + const instance = new FrameElementInit(); + const files = [makeFile('a.txt'), makeFile('b.txt'), makeFile('c.txt'), makeFile('d.txt'), makeFile('e.txt')]; + const fileElement = makeFileElement({ multiple: true, files, maxFileCount: 3 }); + helpers.fileValidation = jest.fn(() => true); + helpers.vaildateFileName = jest.fn(() => true); + let caughtError; + try { + instance['validateFiles'](files, fileElement.state, fileElement); + } catch (err) { + caughtError = err; + } + expect(caughtError.error.description).toBe( + parameterizedString(logs.errorLogs.FILE_COUNT_EXCEEDED, '3'), + ); + }); + + test('validateFiles passes when file count equals maxFileCount', () => { + const instance = new FrameElementInit(); + const files = [makeFile('a.txt'), makeFile('b.txt')]; + const fileElement = makeFileElement({ multiple: true, files, maxFileCount: 2 }); + helpers.fileValidation = jest.fn(() => true); + helpers.vaildateFileName = jest.fn(() => true); + expect(() => instance['validateFiles'](files, fileElement.state, fileElement)).not.toThrow(); + }); + + test('validateFiles passes when file count is within default maxFileCount of 4', () => { + const instance = new FrameElementInit(); + const files = [makeFile('a.txt'), makeFile('b.txt'), makeFile('c.txt'), makeFile('d.txt')]; + const fileElement = makeFileElement({ multiple: true, files }); + helpers.fileValidation = jest.fn(() => true); + helpers.vaildateFileName = jest.fn(() => true); + expect(() => instance['validateFiles'](files, fileElement.state, fileElement)).not.toThrow(); + }); + + test('validateFiles with maxFileCount=1 and single oversized file throws size error not count error', () => { + const instance = new FrameElementInit(); + const largeFile = makeFile('large.pdf', 6000000); + const files = [largeFile]; // 1 file — does NOT exceed maxFileCount=1 + const fileElement = makeFileElement({ multiple: true, files, maxFileCount: 1, maxFileSize: 5000000 }); + helpers.fileValidation = jest.fn(() => { + throw new SkyflowError({ code: 400, description: 'Invalid File Size' }, [], true); + }); + helpers.vaildateFileName = jest.fn(() => true); + // Should throw the size error (from fileValidation), NOT the count error + expect(() => instance['validateFiles'](files, fileElement.state, fileElement)).toThrow(SkyflowError); + expect(helpers.fileValidation).toHaveBeenCalledTimes(1); + }); + + test('validateFiles throws when single file exceeds maxFileSize', () => { + const instance = new FrameElementInit(); + const largeFile = makeFile('large.pdf', 5000001); + const files = [largeFile]; + const fileElement = makeFileElement({ multiple: true, files, maxFileSize: 5000000 }); + helpers.fileValidation = jest.fn(() => { throw new SkyflowError({ code: 400, description: 'Invalid File Size' }, [], true); }); + helpers.vaildateFileName = jest.fn(() => true); + expect(() => instance['validateFiles'](files, fileElement.state, fileElement)).toThrow(SkyflowError); + }); + test('parallelUploadFiles resolves with aggregated responses when all succeed', async () => { const instance = new FrameElementInit(); const fileElementA = makeFileElement({ multiple: false, files: [makeFile('a.txt')] }); diff --git a/tests/core/internal/iframe-form/iframe-form.test.js b/tests/core/internal/iframe-form/iframe-form.test.js index 339b8a2d..ed7f65df 100644 --- a/tests/core/internal/iframe-form/iframe-form.test.js +++ b/tests/core/internal/iframe-form/iframe-form.test.js @@ -1159,3 +1159,152 @@ describe('getFileDetails isolated tests', () => { expect(details).toEqual([]); }); }); + +describe('MULTI_FILE_INPUT validator - specific UI error messages', () => { + // Builds a FileList-like object (DataTransfer is unavailable in this jsdom version) + const makeFileList = (...files) => { + const arr = [...files]; + Object.defineProperty(arr, 'item', { value: (i) => arr[i] }); + if (typeof FileList !== 'undefined') Object.setPrototypeOf(arr, FileList.prototype); + return arr; + }; + + test('invalid file type in MULTI_FILE_INPUT shows generic error not size-specific message', () => { + const element = new IFrameFormElement(multi_file_element, '', { containerType: ContainerType.COLLECT }, context); + element.maxFileSize = 10_000_000; // 10 MB — file below this so size is not the issue + element.allowedFileType = ['.pdf']; + // .zip is not in allowedFileType — fileValidation throws INVALID_FILE_TYPE + const file = { name: 'archive.zip', size: 100, type: 'application/zip' }; + element.state.value = file; + const result = element.validator(file); + expect(result).toBe(false); + // fileSpecificError must NOT be set — size was fine, so no size message + expect(element.errorText).not.toContain('size limit'); + }); + + test('default maxFileSize is 32 MB when no option is provided', () => { + const element = new IFrameFormElement(multi_file_element, '', { containerType: ContainerType.COLLECT }, context); + expect(element.maxFileSize).toBe(32_000_000); + }); + + test('default maxFileCount is 4 when no option is provided', () => { + const element = new IFrameFormElement(multi_file_element, '', { containerType: ContainerType.COLLECT }, context); + expect(element.maxFileCount).toBe(4); + }); + + test('maxFileCount=1 with single oversized file gives size error, not count error', () => { + const element = new IFrameFormElement(multi_file_element, '', { containerType: ContainerType.COLLECT }, context); + element.maxFileCount = 1; + element.maxFileSize = 5_000_000; // 5 MB + const file = { name: 'large.pdf', size: 6_000_000, type: 'application/pdf' }; + element.state.value = file; + const result = element.validator(file); + expect(result).toBe(false); + expect(element.errorText).toBe( + parameterizedString(logs.errorLogs.FILE_SIZE_EXCEEDED_SINGLE, '5 MB'), + ); + }); + + test('single file exceeding configured maxFileSize shows FILE_SIZE_EXCEEDED_SINGLE with correct size', () => { + const element = new IFrameFormElement(multi_file_element, '', { containerType: ContainerType.COLLECT }, context); + element.maxFileSize = 5_000_000; // 5 MB + const file = { name: 'report.pdf', size: 5_000_001, type: 'application/pdf' }; + element.state.value = file; + const result = element.validator(file); + expect(result).toBe(false); + expect(element.errorText).toBe( + parameterizedString(logs.errorLogs.FILE_SIZE_EXCEEDED_SINGLE, '5 MB'), + ); + }); + + test('one of multiple files too large shows FILE_SIZE_EXCEEDED_WITH_NAME with filename and configured size', () => { + const element = new IFrameFormElement(multi_file_element, '', { containerType: ContainerType.COLLECT }, context); + element.maxFileSize = 3; // 3 bytes — keep files tiny for test speed + const okFile = new File(['ok'], 'small.pdf', { type: 'application/pdf' }); // 2 bytes — within limit + const bigFile = new File(['toobig!'], 'bigfile.pdf', { type: 'application/pdf' }); // 7 bytes — over limit + const fileList = makeFileList(okFile, bigFile); + element.state.value = fileList; + const result = element.validator(fileList); + expect(result).toBe(false); + expect(element.errorText).toBe( + parameterizedString(logs.errorLogs.FILE_SIZE_EXCEEDED_WITH_NAME, 'bigfile.pdf', '0 MB'), + ); + }); + + test('oversized file first followed by valid file still reports error (order-independent)', () => { + const element = new IFrameFormElement(multi_file_element, '', { containerType: ContainerType.COLLECT }, context); + element.maxFileSize = 3; // 3 bytes limit + const bigFile = new File(['toobig!'], 'bigfile.pdf', { type: 'application/pdf' }); // 7 bytes — over limit + const okFile = new File(['ok'], 'small.pdf', { type: 'application/pdf' }); // 2 bytes — within limit + const fileList = makeFileList(bigFile, okFile); // oversized file is first + element.state.value = fileList; + const result = element.validator(fileList); + expect(result).toBe(false); + expect(element.errorText).toBe( + parameterizedString(logs.errorLogs.FILE_SIZE_EXCEEDED_WITH_NAME, 'bigfile.pdf', '0 MB'), + ); + }); + + test('multiple oversized files shows all filenames joined in error message', () => { + const element = new IFrameFormElement(multi_file_element, '', { containerType: ContainerType.COLLECT }, context); + element.maxFileSize = 3; // 3 bytes limit + const bigFile1 = new File(['toobig!'], 'large1.pdf', { type: 'application/pdf' }); // 7 bytes — over limit + const bigFile2 = new File(['alsobig'], 'large2.pdf', { type: 'application/pdf' }); // 7 bytes — over limit + const okFile = new File(['ok'], 'small.pdf', { type: 'application/pdf' }); // 2 bytes — within limit + const fileList = makeFileList(bigFile1, okFile, bigFile2); + element.state.value = fileList; + const result = element.validator(fileList); + expect(result).toBe(false); + expect(element.errorText).toBe( + parameterizedString(logs.errorLogs.FILE_SIZE_EXCEEDED_WITH_NAME, 'large1.pdf, large2.pdf', '0 MB'), + ); + }); + + test('file count exceeds configured maxFileCount shows FILE_COUNT_EXCEEDED with that count', () => { + const element = new IFrameFormElement(multi_file_element, '', { containerType: ContainerType.COLLECT }, context); + element.maxFileCount = 2; // configurable — not the default 4 + const fileList = makeFileList( + new File(['a'], 'f1.pdf', { type: 'application/pdf' }), + new File(['b'], 'f2.pdf', { type: 'application/pdf' }), + new File(['c'], 'f3.pdf', { type: 'application/pdf' }), + ); + element.state.value = fileList; + const result = element.validator(fileList); + expect(result).toBe(false); + expect(element.errorText).toBe( + parameterizedString(logs.errorLogs.FILE_COUNT_EXCEEDED, '2'), + ); + }); + + test('FILE_COUNT_EXCEEDED message changes when maxFileCount is different (e.g. 6)', () => { + const element = new IFrameFormElement(multi_file_element, '', { containerType: ContainerType.COLLECT }, context); + element.maxFileCount = 6; + const files = Array.from({ length: 7 }, (_, i) => + new File(['x'], `f${i + 1}.pdf`, { type: 'application/pdf' }), + ); + const fileList = makeFileList(...files); + element.state.value = fileList; + const result = element.validator(fileList); + expect(result).toBe(false); + expect(element.errorText).toBe( + parameterizedString(logs.errorLogs.FILE_COUNT_EXCEEDED, '6'), + ); + }); + + test('count and size both exceeded shows FILE_COUNT_AND_SIZE_EXCEEDED with configured count and size', () => { + const element = new IFrameFormElement(multi_file_element, '', { containerType: ContainerType.COLLECT }, context); + element.maxFileCount = 2; + element.maxFileSize = 3; // 3 bytes + const fileList = makeFileList( + new File(['toobig1'], 'f1.pdf', { type: 'application/pdf' }), // 7 bytes > 3 + new File(['toobig2'], 'f2.pdf', { type: 'application/pdf' }), // 7 bytes > 3 + new File(['toobig3'], 'f3.pdf', { type: 'application/pdf' }), // 7 bytes > 3 + ); + element.state.value = fileList; + const result = element.validator(fileList); + expect(result).toBe(false); + expect(element.errorText).toBe( + parameterizedString(logs.errorLogs.FILE_COUNT_AND_SIZE_EXCEEDED, '2', '0 MB'), + ); + }); +}); diff --git a/tests/core/internal/internal-index.test.js b/tests/core/internal/internal-index.test.js index 93496ce2..0b3a0fdc 100644 --- a/tests/core/internal/internal-index.test.js +++ b/tests/core/internal/internal-index.test.js @@ -2760,4 +2760,82 @@ describe('setDropdownIconStyle Tests', () => { expect(frameElement.dropdownIcon.style.display).toBe('block'); }); +}); + +describe('FrameElement constructor - maxFileSize and maxFileCount options', () => { + let mockIFrameFormElement; + let mockHtmlDivElement; + + beforeEach(() => { + jest.spyOn(bus, 'target').mockReturnValue({ on: jest.fn(), emit: jest.fn() }); + jest.spyOn(bus, 'on'); + mockIFrameFormElement = { + resetEvents: jest.fn(), + on: jest.fn(), + setValue: jest.fn(), + setMask: jest.fn(), + setValidation: jest.fn(), + setReplacePattern: jest.fn(), + setFormat: jest.fn(), + getStatus: jest.fn().mockReturnValue({ + isFocused: false, isValid: true, isEmpty: true, + isComplete: false, isRequired: false, isTouched: false, value: '', + }), + getValue: jest.fn().mockReturnValue(''), + getUnformattedValue: jest.fn().mockReturnValue(''), + onFocusChange: jest.fn(), + onDropdownSelect: jest.fn(), + fieldType: ELEMENTS.MULTI_FILE_INPUT.name, + iFrameName: 'mockMultiFileFrame', + cardType: 'DEFAULT', + state: { value: undefined, isFocused: false, isValid: false, isEmpty: true, isComplete: false, name: '', isRequired: false, isTouched: false }, + mask: [], + replacePattern: '', + maxFileSize: 32_000_000, + maxFileCount: 4, + }; + mockHtmlDivElement = document.createElement('div'); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('sets maxFileSize on iFrameFormElement when option is provided', () => { + const options = { column: 'file', table: 'files', maxFileSize: 5_000_000 }; + new FrameElement(mockIFrameFormElement, options, mockHtmlDivElement); + expect(mockIFrameFormElement.maxFileSize).toBe(5_000_000); + }); + + it('does not overwrite maxFileSize when option is absent', () => { + const options = { column: 'file', table: 'files' }; + new FrameElement(mockIFrameFormElement, options, mockHtmlDivElement); + expect(mockIFrameFormElement.maxFileSize).toBe(32_000_000); + }); + + it('sets maxFileCount on iFrameFormElement when option is provided', () => { + const options = { column: 'file', table: 'files', maxFileCount: 2 }; + new FrameElement(mockIFrameFormElement, options, mockHtmlDivElement); + expect(mockIFrameFormElement.maxFileCount).toBe(2); + }); + + it('does not overwrite maxFileCount when option is absent', () => { + const options = { column: 'file', table: 'files' }; + new FrameElement(mockIFrameFormElement, options, mockHtmlDivElement); + expect(mockIFrameFormElement.maxFileCount).toBe(4); + }); + + it('sets both maxFileSize and maxFileCount together when both are provided', () => { + const options = { column: 'file', table: 'files', maxFileSize: 10_000_000, maxFileCount: 3 }; + new FrameElement(mockIFrameFormElement, options, mockHtmlDivElement); + expect(mockIFrameFormElement.maxFileSize).toBe(10_000_000); + expect(mockIFrameFormElement.maxFileCount).toBe(3); + }); + + it('sets maxFileSize to 0 when explicitly passed as 0 (hasOwnProperty check, not falsy check)', () => { + const options = { column: 'file', table: 'files', maxFileSize: 0 }; + new FrameElement(mockIFrameFormElement, options, mockHtmlDivElement); + expect(mockIFrameFormElement.maxFileSize).toBe(0); + }); }); \ No newline at end of file diff --git a/tests/libs/element-options.test.js b/tests/libs/element-options.test.js index 8e254b1f..f1673fd2 100644 --- a/tests/libs/element-options.test.js +++ b/tests/libs/element-options.test.js @@ -193,6 +193,85 @@ describe('test formatOptions function with format and translation', () => { expect(options).toEqual({cardMetadata:{scheme:[CardType.VISA,CardType.CARTES_BANCAIRES]}, "cardSeperator": " ","enableCardIcon": true,"required": false,}) }); + test('should include maxFileSize in formatted options for MULTI_FILE_INPUT', () => { + const formattedOptions = formatOptions(ElementType.MULTI_FILE_INPUT, { maxFileSize: 4000000 }, LogLevel.ERROR); + expect(formattedOptions.maxFileSize).toBe(4000000); + }); + + test('should throw error for maxFileSize provided as non-number for MULTI_FILE_INPUT', (done) => { + try { + formatOptions(ElementType.MULTI_FILE_INPUT, { maxFileSize: 'large' }, LogLevel.ERROR); + done('should throw error'); + } catch (err) { + expect(err?.error?.description).toEqual(parameterizedString(SKYFLOW_ERROR_CODE.INVALID_POSITIVE_NUMBER_OPTIONS.description, 'maxFileSize')); + done(); + } + }); + + test('should throw error for maxFileSize provided as zero for MULTI_FILE_INPUT', (done) => { + try { + formatOptions(ElementType.MULTI_FILE_INPUT, { maxFileSize: 0 }, LogLevel.ERROR); + done('should throw error'); + } catch (err) { + expect(err?.error?.description).toEqual(parameterizedString(SKYFLOW_ERROR_CODE.INVALID_POSITIVE_NUMBER_OPTIONS.description, 'maxFileSize')); + done(); + } + }); + + test('should throw error for maxFileSize provided as negative number for MULTI_FILE_INPUT', (done) => { + try { + formatOptions(ElementType.MULTI_FILE_INPUT, { maxFileSize: -1000 }, LogLevel.ERROR); + done('should throw error'); + } catch (err) { + expect(err?.error?.description).toEqual(parameterizedString(SKYFLOW_ERROR_CODE.INVALID_POSITIVE_NUMBER_OPTIONS.description, 'maxFileSize')); + done(); + } + }); + + test('should include maxFileCount in formatted options for MULTI_FILE_INPUT', () => { + const formattedOptions = formatOptions(ElementType.MULTI_FILE_INPUT, { maxFileCount: 2 }, LogLevel.ERROR); + expect(formattedOptions.maxFileCount).toBe(2); + }); + + test('should throw error for maxFileCount provided as non-integer for MULTI_FILE_INPUT', (done) => { + try { + formatOptions(ElementType.MULTI_FILE_INPUT, { maxFileCount: 2.5 }, LogLevel.ERROR); + done('should throw error'); + } catch (err) { + expect(err?.error?.description).toEqual(parameterizedString(SKYFLOW_ERROR_CODE.INVALID_POSITIVE_NUMBER_OPTIONS.description, 'maxFileCount')); + done(); + } + }); + + test('should throw error for maxFileCount provided as zero for MULTI_FILE_INPUT', (done) => { + try { + formatOptions(ElementType.MULTI_FILE_INPUT, { maxFileCount: 0 }, LogLevel.ERROR); + done('should throw error'); + } catch (err) { + expect(err?.error?.description).toEqual(parameterizedString(SKYFLOW_ERROR_CODE.INVALID_POSITIVE_NUMBER_OPTIONS.description, 'maxFileCount')); + done(); + } + }); + + test('should throw error for maxFileCount provided as negative number for MULTI_FILE_INPUT', (done) => { + try { + formatOptions(ElementType.MULTI_FILE_INPUT, { maxFileCount: -1 }, LogLevel.ERROR); + done('should throw error'); + } catch (err) { + expect(err?.error?.description).toEqual(parameterizedString(SKYFLOW_ERROR_CODE.INVALID_POSITIVE_NUMBER_OPTIONS.description, 'maxFileCount')); + done(); + } + }); + + test('should not include maxFileSize in formatted options for FILE_INPUT', () => { + const formattedOptions = formatOptions(ElementType.FILE_INPUT, { maxFileSize: 4000000 }, LogLevel.ERROR); + expect(formattedOptions.maxFileSize).toBeUndefined(); + }); + + test('should not include maxFileCount in formatted options for FILE_INPUT', () => { + const formattedOptions = formatOptions(ElementType.FILE_INPUT, { maxFileCount: 2 }, LogLevel.ERROR); + expect(formattedOptions.maxFileCount).toBeUndefined(); + }); }); diff --git a/tests/utils/helpers.test.js b/tests/utils/helpers.test.js index 73d63091..0466d85d 100644 --- a/tests/utils/helpers.test.js +++ b/tests/utils/helpers.test.js @@ -324,6 +324,63 @@ describe('test file validation', () => { } expect(fileValidation(file, false, {allowedFileType: ['image/jpeg']})).toBe(true); }) + + test('valid file within custom maxFileSize limit', () => { + const file = { + name: "sample.pdf", + size: 3000000, + type: "application/pdf", + } + expect(fileValidation(file, false, { maxFileSize: 4000000 })).toBe(true); + }) + + test('invalid file exceeding custom maxFileSize limit', () => { + const file = { + name: "sample.pdf", + size: 5000000, + type: "application/pdf", + } + expect(() => { + fileValidation(file, false, { maxFileSize: 4000000 }); + }).toThrowError(expect.objectContaining({ + error: expect.objectContaining({ + description: parameterizedString(SKYFLOW_ERROR_CODE.INVALID_FILE_SIZE.description) + }) + })); + }) + + test('file at exact custom maxFileSize boundary is valid', () => { + const file = { + name: "sample.pdf", + size: 4000000, + type: "application/pdf", + } + expect(fileValidation(file, false, { maxFileSize: 4000000 })).toBe(true); + }) + + test('falls back to 32MB default when maxFileSize not provided: file just within limit', () => { + const file = { + name: "sample.pdf", + size: 32000000, + type: "application/pdf", + } + expect(fileValidation(file, false, {})).toBe(true); + }) + + test('falls back to 32MB default when maxFileSize not provided: file exceeds limit', () => { + const file = { + name: "sample.pdf", + size: 32000001, + type: "application/pdf", + } + expect(() => { + fileValidation(file, false, {}); + }).toThrowError(expect.objectContaining({ + error: expect.objectContaining({ + description: parameterizedString(SKYFLOW_ERROR_CODE.INVALID_FILE_SIZE.description) + }) + })); + }) })