Skip to content

Commit

Permalink
fix(file): added file extension validation
Browse files Browse the repository at this point in the history
ref: MANAGER-15044

Signed-off-by: Jacques Larique <jacques.larique.ext@ovhcloud.com>
  • Loading branch information
Jacques Larique authored and JacquesLarique committed Aug 20, 2024
1 parent f6a0eb8 commit 3d451de
Show file tree
Hide file tree
Showing 3 changed files with 114 additions and 14 deletions.
1 change: 1 addition & 0 deletions packages/components/field/src/js/field.provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
},
};
Expand Down
53 changes: 49 additions & 4 deletions packages/components/file/src/js/file.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down Expand Up @@ -68,6 +88,7 @@ export default class {
}

addFile(file) {
if (!file) return;
this.getFileInfos(file);
this.checkFileValidity(file);

Expand Down Expand Up @@ -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 });
}
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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');
Expand All @@ -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() {
Expand Down
74 changes: 64 additions & 10 deletions packages/components/file/src/js/file.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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'));

Expand All @@ -315,16 +329,56 @@ 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();

controller.maxsize = 100000;
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);
});
});
});
Expand Down

0 comments on commit 3d451de

Please sign in to comment.