From 3d451de6a725a4ad445d2cc913cddc1def7673bf Mon Sep 17 00:00:00 2001 From: Jacques Larique Date: Wed, 14 Aug 2024 10:11:39 +0200 Subject: [PATCH] fix(file): added file extension validation ref: MANAGER-15044 Signed-off-by: Jacques Larique --- .../components/field/src/js/field.provider.js | 1 + .../components/file/src/js/file.controller.js | 53 ++++++++++++- packages/components/file/src/js/file.spec.js | 74 ++++++++++++++++--- 3 files changed, 114 insertions(+), 14 deletions(-) diff --git a/packages/components/field/src/js/field.provider.js b/packages/components/field/src/js/field.provider.js index 6be7c1a02..e8c0908c0 100644 --- a/packages/components/field/src/js/field.provider.js +++ b/packages/components/field/src/js/field.provider.js @@ -14,6 +14,7 @@ export default class { minlength: 'Too short ({{minlength}} characters min).', maxlength: 'Too high ({{maxlength}} characters max).', maxsize: 'This file exceeds the size limit', + type: 'This file extension is not supported', pattern: 'Invalid format.', }, }; diff --git a/packages/components/file/src/js/file.controller.js b/packages/components/file/src/js/file.controller.js index 3c064d509..696a81619 100644 --- a/packages/components/file/src/js/file.controller.js +++ b/packages/components/file/src/js/file.controller.js @@ -32,9 +32,29 @@ export default class { file.errors.maxsize = true; } + // Check extension / type + if (this.accept) { + const [fileType, fileExtension] = file.type.split('/'); + const acceptedTypes = this.accept.split(','); + file.errors.type = !acceptedTypes.some((acceptedType) => { + const [type, extension] = acceptedType.split('/'); + if (extension) { + const isTypeValid = type === '*' || type.toLowerCase() === fileType.toLowerCase(); + const isExtensionValid = extension === '*' || extension.toLowerCase() === fileExtension.toLowerCase(); + return isTypeValid && isExtensionValid; + } + return type === '*' || type.replace('.', '').toLowerCase() === fileExtension.toLowerCase(); + }); + } + // Set form validation if (this.form && this.form[this.name]) { - this.form[this.name].$setValidity('maxsize', !file.errors.maxsize); + if (file.errors.maxsize) { + this.form[this.name].$setValidity('maxsize', false); + } + if (file.errors.type) { + this.form[this.name].$setValidity('type', false); + } this.form[this.name].$setDirty(); } @@ -68,6 +88,7 @@ export default class { } addFile(file) { + if (!file) return; this.getFileInfos(file); this.checkFileValidity(file); @@ -103,9 +124,15 @@ export default class { removeFile(file) { if (angular.isArray(this.model)) { remove(this.model, (item) => item === file); - if (file.errors && this.model.every((item) => !item.errors) - && this.form && this.form[this.name]) { - this.form[this.name].$setValidity('maxsize', true); + if (file.errors && this.form && this.form[this.name]) { + let hasMaxsizeErrors = false; + let hasTypeErrors = false; + this.model.forEach((item) => { + hasMaxsizeErrors = hasMaxsizeErrors || item.errors?.maxsize; + hasTypeErrors = hasTypeErrors || item.errors?.type; + }); + this.form[this.name].$setValidity('maxsize', !hasMaxsizeErrors); + this.form[this.name].$setValidity('type', !hasTypeErrors); } this.onRemove({ modelValue: this.model }); } @@ -118,6 +145,7 @@ export default class { if (this.form && this.form[this.name]) { this.form[this.name].$setValidity('maxsize', true); + this.form[this.name].$setValidity('type', true); } } @@ -176,6 +204,21 @@ export default class { } } + parseAcceptAttribute() { + const acceptedTypes = this.accept?.split(',') || []; + let accept = ''; + acceptedTypes.forEach((acceptedType) => { + const isExtension = acceptedType.indexOf('/') === -1; + const isValid = isExtension + ? acceptedType.startsWith('.') + : /^([\x20-\x7F]+|\*)\/([\x20-\x7F]+|\*)$/.test(acceptedType);// match "string/string","*/string" and "string/*" + if (isValid) { + accept = accept ? `${accept},${acceptedType}` : acceptedType; + } + }); + return accept; + } + $onInit() { addBooleanParameter(this, 'disabled'); addBooleanParameter(this, 'required'); @@ -190,6 +233,8 @@ export default class { this.selectorId = `${this.id}Selector`; this.dropareaId = `${this.id}Droparea`; this.attachments = Boolean(this.multiple || this.droparea || this.preview); + + this.accept = this.parseAcceptAttribute(); } $postLink() { diff --git a/packages/components/file/src/js/file.spec.js b/packages/components/file/src/js/file.spec.js index 47b99f503..0f9f87f27 100644 --- a/packages/components/file/src/js/file.spec.js +++ b/packages/components/file/src/js/file.spec.js @@ -17,6 +17,12 @@ describe('ouiFile', () => { type: 'image/png', }; + const invalidMockFile = { + name: 'test_invalid.png', + size: 500000, + type: 'image/jpeg', + }; + const mockFiles = [mockFile]; beforeEach(angular.mock.module('oui.file')); @@ -263,14 +269,6 @@ describe('ouiFile', () => { expect(controller.model.length).toBe(0); expect(onRemoveSpy).toHaveBeenCalledWith(controller.model); }); - - it('should clear errors after file removal', () => { - controller.maxsize = 100000; - controller.addFiles(mockFiles); - expect(controller.form[controller.name].$invalid).toBe(true); - controller.removeFile(mockFile); - expect(controller.form[controller.name].$invalid).toBe(false); - }); }); describe('Form controls', () => { @@ -302,6 +300,22 @@ describe('ouiFile', () => { expect(element.find('oui-file').attr('name')).toBeUndefined(); }); + // Due to an unexplained issue, file component's controller is correctly updated but the + // variable controller used here is not, to bypass this we're triggering the $onInit by hand + it('should ignore invalid accept attribute', () => { + controller.accept = 'test'; + controller.$onInit(); + expect(controller.accept).toBe(''); + + controller.accept = '.png'; + controller.$onInit(); + expect(controller.accept).toBe('.png'); + + controller.accept = '*/png'; + controller.$onInit(); + expect(controller.accept).toBe('*/png'); + }); + it('should set input form $touched', () => { const label = angular.element(element[0].querySelector('.oui-file-selector__label')); @@ -315,7 +329,7 @@ describe('ouiFile', () => { controller.maxsize = 200000; controller.checkFileValidity(mockFile); - expect(controller.form[name].$error.maxsize).toBeUndefined(); + expect(controller.form[name].$error.maxsize).toBeFalsy(); expect(controller.form[name].$dirty).toBeTruthy(); @@ -323,8 +337,48 @@ describe('ouiFile', () => { controller.checkFileValidity(mockFile); expect(controller.form[name].$error.maxsize).toBeTruthy(); + // Valid extension tests + controller.accept = 'image/png'; + controller.checkFileValidity(mockFile); + expect(controller.form[name].$error.type).toBeFalsy(); + + controller.accept = 'image/*'; + controller.checkFileValidity(mockFile); + expect(controller.form[name].$error.type).toBeFalsy(); + + controller.accept = '*/png'; + controller.checkFileValidity(mockFile); + expect(controller.form[name].$error.type).toBeFalsy(); + + controller.accept = '.png'; + controller.checkFileValidity(mockFile); + expect(controller.form[name].$error.type).toBeFalsy(); + + // Invalid extension tests + controller.accept = 'image/jpeg'; + controller.checkFileValidity(mockFile); + expect(controller.form[name].$error.type).toBeTruthy(); + controller.resetFile(); - expect(controller.form[name].$error.maxsize).toBeUndefined(); + expect(controller.form[name].$error.maxsize).toBeFalsy(); + expect(controller.form[name].$error.type).toBeFalsy(); + }); + + it('should not set form as valid after uploading an invalid and then a valid file', () => { + controller.maxsize = 200000; + controller.addFile(invalidMockFile); + expect(controller.form[controller.name].$invalid).toBe(true); + controller.addFile(mockFile); + expect(controller.form[controller.name].$invalid).toBe(true); + }); + + it('should clear errors after file removal', () => { + controller.maxsize = 200000; + controller.accept = 'image/png'; + controller.addFiles([mockFile, invalidMockFile]); + expect(controller.form[controller.name].$invalid).toBe(true); + controller.removeFile(invalidMockFile); + expect(controller.form[controller.name].$invalid).toBe(false); }); }); });