From 677e24ecedc60d313afcfdc7eb56823794450346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eirik=20S=C3=B8reng?= Date: Mon, 12 Feb 2024 14:35:46 +0100 Subject: [PATCH] Bug fix: Attachment max file size validation Fix on attachment file size: Rules: 1) Questionnaire Item max rules 2) Props 3) Refero Constant --- CHANGES | 6 + package-lock.json | 4 +- package.json | 2 +- .../attachment/__tests__/attachment-spec.tsx | 215 ++++++++++++++++++ .../formcomponents/attachment/attachment.tsx | 4 +- .../attachment/attachmentUtil.ts | 38 ++++ .../attachment/attachmenthtml.tsx | 26 ++- .../formcomponents/attachment/mockUtil.tsx | 119 ++++++++++ src/constants/extensions.ts | 2 + src/util/extension.ts | 11 + 10 files changed, 417 insertions(+), 10 deletions(-) create mode 100644 src/components/formcomponents/attachment/__tests__/attachment-spec.tsx create mode 100644 src/components/formcomponents/attachment/attachmentUtil.ts create mode 100644 src/components/formcomponents/attachment/mockUtil.tsx diff --git a/CHANGES b/CHANGES index 15c305e9..72cb8221 100644 --- a/CHANGES +++ b/CHANGES @@ -1,3 +1,9 @@ +## 14.0.7 + +--- + +- Bugfix: Attachment Validation based on props max value, questionnaire item max attachment size rules and refero constant + ## 14.0.5 --- diff --git a/package-lock.json b/package-lock.json index 092b361e..2037d95f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@helsenorge/refero", - "version": "14.0.1", + "version": "14.0.7", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@helsenorge/refero", - "version": "14.0.1", + "version": "14.0.7", "license": "MIT", "dependencies": { "@types/react-collapse": "^5.0.1", diff --git a/package.json b/package.json index 66ebad64..ab761cc6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@helsenorge/refero", - "version": "14.0.5", + "version": "14.0.7", "engines": { "node": "^18.0.0", "npm": ">=9.0.0" diff --git a/src/components/formcomponents/attachment/__tests__/attachment-spec.tsx b/src/components/formcomponents/attachment/__tests__/attachment-spec.tsx new file mode 100644 index 00000000..bd0c21b9 --- /dev/null +++ b/src/components/formcomponents/attachment/__tests__/attachment-spec.tsx @@ -0,0 +1,215 @@ +import * as React from 'react'; + +import { Matcher, render, screen } from '@testing-library/react'; + +import userEvent from '@testing-library/user-event'; + +import '@testing-library/jest-dom'; + +import { + MimeType_For_Test_Util as MIME_TYPES_TEST, + createMockAttachmentProps, + createMockFile, + createMockQuestionnaireItem, + createMockQuestionnaireItemWithEmptyValue, +} from '../mockUtil'; +import { Resources } from '../../../../util/resources'; +import { convertBytesToMBString, convertMBToBytes } from '../attachmentUtil'; +import constants from '../../../../constants'; +import { AttachmentComponent } from '../attachment'; + +beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated but included for compatibility + removeListener: jest.fn(), // Deprecated but included for compatibility + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); +}); + +const mockFileTooLarge = 'Filstørrelsen må være mindre enn {0} MB'; +const wrongFileTypeMsg = 'Feil filtype'; +const mockFileName = 'testFile.txt'; +const defaulMockSize = 3; +const qItemMockName = 'qItem'; +const PLAIN_TEXT_3_MB = createMockFile(mockFileName, MIME_TYPES_TEST.PlainText, convertMBToBytes(defaulMockSize)); +const PLAIN_TEXT_4_MB = createMockFile(mockFileName, MIME_TYPES_TEST.PlainText, convertMBToBytes(4)); +const PLAIN_TEXT_5_MB = createMockFile(mockFileName, MIME_TYPES_TEST.PlainText, convertMBToBytes(5)); +const JPEG_5_MB = createMockFile(mockFileName, MIME_TYPES_TEST.JPG, convertMBToBytes(5)); +const PLAIN_TEXT_6_MB = createMockFile(mockFileName, MIME_TYPES_TEST.PlainText, convertMBToBytes(6)); +const PLAIN_TEXT_30_MB = createMockFile(mockFileName, MIME_TYPES_TEST.PlainText, convertMBToBytes(30)); + +const mockResources: Partial = { + validationFileMax: mockFileTooLarge, + validationFileType: wrongFileTypeMsg, +}; + +const expectReplacedFileSizeError = (number: any) => { + const resourceStringWithNumber = mockFileTooLarge.replace('{0}', number); + expect(screen.getByText(resourceStringWithNumber)).toBeInTheDocument(); +}; + +async function uploadMockFile(mockFile: File | File[], label = 'Last opp fil') { + const input = screen.getByLabelText(label); + await userEvent.upload(input, mockFile); +} + +export const expectNotToFindByText = (text: Matcher) => { + expect(screen.queryByText(text)).toBeNull(); +}; + +function expectNoFileErrors() { + expect(screen.queryByText(wrongFileTypeMsg)).toBe(null); + expect(screen.queryByText(mockFileTooLarge)).toBe(null); +} + +describe('', () => { + describe('File Type validation', () => { + it('When uploading a file - Show error if mime type is NOT among valid types', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, undefined, true); + const validTypes = [MIME_TYPES_TEST.PNG, MIME_TYPES_TEST.JPG, MIME_TYPES_TEST.PDF]; + const mockProps = createMockAttachmentProps(qItem, mockResources, undefined, undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_3_MB); + expect(screen.getByText(wrongFileTypeMsg)).toBeInTheDocument(); + }); + + it('When uploading a file - Do NOT show error file type error message when valid mime', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, undefined, false); + const validTypes = [MIME_TYPES_TEST.PNG, MIME_TYPES_TEST.JPG, MIME_TYPES_TEST.PDF, MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, undefined, undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_3_MB); + expectNotToFindByText(wrongFileTypeMsg); + }); + }); + + describe('File Size validation - Questionnaire Extension', () => { + it('When uploading a file - Show resource size error if size > max rule in qItem', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, 5, true); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, undefined, undefined, validTypes); + render(); + + await uploadMockFile(PLAIN_TEXT_6_MB); + + screen.debug(undefined, 6000000); + expectReplacedFileSizeError(5); + expectNotToFindByText(wrongFileTypeMsg); + }); + + it('When uploading a file - Do NOT show resource size error if size <= max rule in qItem', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, 5, true); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, undefined, undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_4_MB); + expectNoFileErrors(); + }); + + it('When uploading a file - Do NOT show resource size error if file size excactly max rule from qItem', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, 5, true); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, undefined, undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_5_MB); + expectNoFileErrors(); + }); + + it('When uploading a file - And not set Questionnaire item max rule will be read as null and should be skipped', async () => { + const qItem = createMockQuestionnaireItemWithEmptyValue('test', null); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, convertMBToBytes(4), undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_5_MB); + expectReplacedFileSizeError(4); + }); + + it('When uploading a file - And not set Questionnaire item max rule with undefined value should be skipped', async () => { + const qItem = createMockQuestionnaireItemWithEmptyValue('test', undefined); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, convertMBToBytes(4), undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_5_MB); + expectReplacedFileSizeError(4); + }); + }); + + describe('File Size validation - Max Setttings From Props', () => { + it('When uploading a file - Show resource size error if filesize > Props Max', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, undefined, false); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, convertMBToBytes(5), undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_6_MB); + expectReplacedFileSizeError(5); + expectNotToFindByText(wrongFileTypeMsg); + }); + + it('When uploading a file - Do NOT show size error message - when file size excatly == props max value', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, undefined, false); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, convertMBToBytes(4), undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_4_MB); + expectNoFileErrors(); + }); + + it('When uploading a file - Do NOT show resource size error if size == excactly props max value', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, undefined, false); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, convertMBToBytes(5), undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_5_MB); + expectNoFileErrors(); + }); + }); + + describe('File validation - Prioritiy of rules', () => { + it('When uploading a file - File type errors should have priority over other errors', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, 2, true); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, convertMBToBytes(4), undefined, validTypes); + render(); + await uploadMockFile(JPEG_5_MB); + expect(screen.getByText(wrongFileTypeMsg)).toBeInTheDocument(); + }); + + it('When uploading a file - Questionniare Item Max Rule has priority over props if both set', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, 2, true); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, convertMBToBytes(4), undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_3_MB); + expectReplacedFileSizeError(2); + expectNotToFindByText(wrongFileTypeMsg); + }); + + it('When uploading a file - And questionnaire max rule is not set, use props max value if set', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, undefined, false); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, convertMBToBytes(4), undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_5_MB); + expectReplacedFileSizeError(4); + expectNotToFindByText(wrongFileTypeMsg); + }); + + it('When uploading a file - Refero constant should be fallback if neither qItem rule or props', async () => { + const qItem = createMockQuestionnaireItem(qItemMockName, 2, false); + const validTypes = [MIME_TYPES_TEST.PlainText]; + const mockProps = createMockAttachmentProps(qItem, mockResources, undefined, undefined, validTypes); + render(); + await uploadMockFile(PLAIN_TEXT_30_MB); + expectReplacedFileSizeError(convertBytesToMBString(constants.MAX_FILE_SIZE)); + expectNotToFindByText(wrongFileTypeMsg); + }); + }); +}); \ No newline at end of file diff --git a/src/components/formcomponents/attachment/attachment.tsx b/src/components/formcomponents/attachment/attachment.tsx index 1c181300..9f489f03 100644 --- a/src/components/formcomponents/attachment/attachment.tsx +++ b/src/components/formcomponents/attachment/attachment.tsx @@ -52,7 +52,7 @@ export interface Props { onRenderMarkdown?: (item: QuestionnaireItem, markdown: string) => string; } -class AttachmentComponent extends React.Component { +export class AttachmentComponent extends React.Component { onUpload = (files: File[], cb: (success: boolean, errormessage: TextMessage | null, uploadedFile?: UploadedFile) => void): void => { const { uploadAttachment, path, item, onAnswerChange } = this.props; if (uploadAttachment) { @@ -203,4 +203,4 @@ class AttachmentComponent extends React.Component { const withCommonFunctionsComponent = withCommonFunctions(AttachmentComponent); const connectedComponent = connect(mapStateToProps, mapDispatchToProps, mergeProps)(withCommonFunctionsComponent); -export default connectedComponent; +export default connectedComponent; \ No newline at end of file diff --git a/src/components/formcomponents/attachment/attachmentUtil.ts b/src/components/formcomponents/attachment/attachmentUtil.ts new file mode 100644 index 00000000..0a9adce9 --- /dev/null +++ b/src/components/formcomponents/attachment/attachmentUtil.ts @@ -0,0 +1,38 @@ +export function convertMBToBytes(mb: number): number { + if (typeof mb !== 'number' || isNaN(mb)) { + throw new Error('Input must be a valid number.'); + } + if (mb < 0) { + throw new Error('Input cannot be a negative number.'); + } + if (!isFinite(mb)) { + throw new Error('Input must be a finite number.'); + } + return Math.round(mb * 1024 * 1024); +} + +export function convertBytesToMBString(bytes: number, precision: number = 0): string { + if (typeof bytes !== 'number' || isNaN(bytes)) { + throw new Error('Input must be a valid number.'); + } + if (bytes < 0) { + throw new Error('Input cannot be a negative number.'); + } + if (!isFinite(bytes)) { + throw new Error('Input must be a finite number.'); + } + return (bytes / 1024 / 1024).toFixed(precision); +} + +export function convertBytesToMB(bytes: number): number { + if (typeof bytes !== 'number' || isNaN(bytes)) { + throw new Error('Input must be a valid number.'); + } + if (bytes < 0) { + throw new Error('Input cannot be a negative number.'); + } + if (!isFinite(bytes)) { + throw new Error('Input must be a finite number.'); + } + return bytes / 1024 / 1024; +} \ No newline at end of file diff --git a/src/components/formcomponents/attachment/attachmenthtml.tsx b/src/components/formcomponents/attachment/attachmenthtml.tsx index 546050a2..289d0dfa 100644 --- a/src/components/formcomponents/attachment/attachmenthtml.tsx +++ b/src/components/formcomponents/attachment/attachmenthtml.tsx @@ -11,8 +11,9 @@ import { UploadedFile } from '@helsenorge/file-upload/components/dropzone'; import { sizeIsValid, mimeTypeIsValid } from '@helsenorge/file-upload/components/dropzone/validation'; import Validation, { ValidationProps } from '@helsenorge/form/components/form/validation'; +import { convertBytesToMBString, convertMBToBytes } from './attachmentUtil'; import constants, { VALID_FILE_TYPES } from '../../../constants'; -import { getValidationTextExtension } from '../../../util/extension'; +import { getMaxSizeExtensionValue, getValidationTextExtension } from '../../../util/extension'; import { Resources } from '../../../util/resources'; interface Props { @@ -66,7 +67,8 @@ const attachmentHtml: React.SFC = ({ children, ...other }) => { - const maxFilesize = attachmentMaxFileSize ? attachmentMaxFileSize : constants.MAX_FILE_SIZE; + const getMaxValueBytes = getAttachmentMaxSizeBytesToUse(attachmentMaxFileSize, item); + const getMaxValueMBToReplace = convertBytesToMBString(getMaxValueBytes); const validFileTypes = attachmentValidTypes ? attachmentValidTypes : VALID_FILE_TYPES; const deleteText = resources ? resources.deleteAttachmentText : undefined; @@ -82,13 +84,13 @@ const attachmentHtml: React.SFC = ({ onOpenFile={onOpen} uploadButtonText={uploadButtonText} uploadedFiles={uploadedFiles} - maxFileSize={maxFilesize} + maxFileSize={getMaxValueBytes} validMimeTypes={validFileTypes} dontShowHardcodedText={!!deleteText} deleteText={deleteText} supportedFileFormatsText={resources ? resources.supportedFileFormats : undefined} errorMessage={(file: File): string => { - return getErrorMessage(validFileTypes, maxFilesize, item, errorText, file, resources); + return getErrorMessage(validFileTypes, getMaxValueBytes, getMaxValueMBToReplace, item, errorText, file, resources); }} isRequired={isRequired} wrapperClasses="page_refero__input" @@ -107,9 +109,23 @@ const attachmentHtml: React.SFC = ({ ); }; +export function getAttachmentMaxSizeBytesToUse(defaultMaxProps: number | undefined, item: QuestionnaireItem): number { + if (item) { + const questionnaireMaxRuleSizeMB = getMaxSizeExtensionValue(item); + if (questionnaireMaxRuleSizeMB !== undefined) { + return convertMBToBytes(questionnaireMaxRuleSizeMB); + } + } + if (defaultMaxProps !== undefined) { + return defaultMaxProps; + } + return constants.MAX_FILE_SIZE; +} + function getErrorMessage( validFileTypes: Array, maxFileSize: number, + maxFileSizeMBStringToReplace: string, item: QuestionnaireItem, genericErrorText?: string, file?: File, @@ -119,7 +135,7 @@ function getErrorMessage( if (!mimeTypeIsValid(file, validFileTypes)) { return resources.validationFileType; } else if (!sizeIsValid(file, maxFileSize)) { - return resources.validationFileMax; + return resources.validationFileMax.replace('{0}', maxFileSizeMBStringToReplace); } } diff --git a/src/components/formcomponents/attachment/mockUtil.tsx b/src/components/formcomponents/attachment/mockUtil.tsx new file mode 100644 index 00000000..ef728721 --- /dev/null +++ b/src/components/formcomponents/attachment/mockUtil.tsx @@ -0,0 +1,119 @@ +import * as React from 'react'; + +import { Questionnaire, QuestionnaireItem, QuestionnaireResponseItem } from 'fhir/r4'; + +import { QuestionnaireStatusCodes } from '../../../types/fhirEnums'; + +import { Props } from './attachment'; +import { Resources } from '../../../util/resources'; + +const mockQuestionnaire: Questionnaire = { + resourceType: 'Questionnaire', + status: QuestionnaireStatusCodes.ACTIVE, +}; + +const mockQuestionnaireResponseMock: QuestionnaireResponseItem = { + linkId: '', +}; + +/** Mock Testing Enum */ +export const MimeType_For_Test_Util = { + PlainText: 'text/plain', + HTML: 'text/html', + CSV: 'text/csv', + JPG: 'image/jpeg', + PNG: 'image/png', + GIF: 'image/gif', + PDF: 'application/pdf', + JSON: 'application/json', +}; + +/** Mock Testing Util method */ +export function createMockFile(fileName: string, mimeType: string, size: number): File { + // Initialize content with the specified size (the content itself doesn't matter for the mock) + const fileContent = new Array(size).fill('a').join(''); + const blob = new Blob([fileContent], { type: mimeType }); + const lastModifiedDate = new Date(); + const mockFile = new File([blob], fileName, { + type: mimeType, + lastModified: lastModifiedDate.getTime(), + }); + // Emulate the actual size (the size of the content may differ from the 'size' parameter due to encoding) + Object.defineProperty(mockFile, 'size', { + value: size, + writable: false, + }); + return mockFile; +} + +/** Mock Testing Util method */ +export function createMockAttachmentProps( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + item: QuestionnaireItem | any, + partialResources: Partial, + attachmentMaxFileSize?: number, + attachmentErrorMessage?: string, + attachmentValidTypes?: string[] +): Props { + // Create a base mock with required and default properties. + const mockProps: Partial = { + dispatch: jest.fn(), + path: [], + item, // Required parameter + questionnaire: mockQuestionnaire, + responseItem: mockQuestionnaireResponseMock, + answer: [], + resources: partialResources as Resources, + renderDeleteButton: jest.fn(() => ), + repeatButton: , + renderHelpButton: jest.fn(() => ), + renderHelpElement: jest.fn(() =>
{'Help content'}
), + onAnswerChange: jest.fn(), + // ... other props with their mock implementations + }; + if (attachmentMaxFileSize !== undefined) { + mockProps.attachmentMaxFileSize = attachmentMaxFileSize; + } + if (attachmentErrorMessage !== undefined) { + mockProps.attachmentErrorMessage = attachmentErrorMessage; + } + if (attachmentValidTypes !== undefined) { + mockProps.attachmentValidTypes = attachmentValidTypes; + } + return mockProps as Props; +} + +/** Mock Testing Util method */ +export function createMockQuestionnaireItem(text: string, valueDecimal: number | undefined, includeExtension: boolean): QuestionnaireItem { + const questionnaireItem: QuestionnaireItem = { + linkId: '4c71df6e-d743-46ba-d81f-f62777ffddb4', + type: 'attachment', + text: text, + }; + if (includeExtension) { + questionnaireItem.extension = [ + { + url: 'http://hl7.org/fhir/StructureDefinition/maxSize', + valueDecimal: valueDecimal, + }, + ]; + } + return questionnaireItem; +} + +/** Mock Testing Util method */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function createMockQuestionnaireItemWithEmptyValue(text: string, valueDecimalEmpty: null | undefined) { + const questionnaireItem = { + linkId: '4c71df6e-d743-46ba-d81f-f62777ffddb4', + type: 'attachment', + text: text, + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/maxSize', + valueDecimal: valueDecimalEmpty, + }, + ], + }; + return questionnaireItem; +} diff --git a/src/constants/extensions.ts b/src/constants/extensions.ts index 7ba61a4a..e2427b85 100644 --- a/src/constants/extensions.ts +++ b/src/constants/extensions.ts @@ -30,4 +30,6 @@ export default { HYPERLINK: 'http://helsenorge.no/fhir/StructureDefinition/sdf-hyperlink-target', VALUESET_LABEL: 'http://hl7.org/fhir/StructureDefinition/valueset-label', + + MAX_SIZE_URL: 'http://hl7.org/fhir/StructureDefinition/maxSize', }; diff --git a/src/util/extension.ts b/src/util/extension.ts index 42e78e00..cb6eceac 100644 --- a/src/util/extension.ts +++ b/src/util/extension.ts @@ -225,3 +225,14 @@ export function getHyperlinkExtensionValue(item: QuestionnaireItem | Element | Q } return undefined; } + +export function getMaxSizeExtensionValue(item: QuestionnaireItem): number | undefined { + const maxValue = getExtension(ExtensionConstants.MAX_SIZE_URL, item); + if (maxValue && maxValue.valueDecimal !== null && maxValue.valueDecimal !== undefined) { + return Number(maxValue.valueDecimal); + } + if (maxValue && maxValue.valueInteger !== null && maxValue.valueInteger !== undefined) { + return Number(maxValue.valueInteger); + } + return undefined; +} \ No newline at end of file