diff --git a/source/frontend/package-lock.json b/source/frontend/package-lock.json index 1eff9e1fe5..17f37aee62 100644 --- a/source/frontend/package-lock.json +++ b/source/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "6.1.0-118.0", + "version": "6.1.0-118.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "6.1.0-118.0", + "version": "6.1.0-118.12", "dependencies": { "@bcgov/bc-sans": "1.0.1", "@bcgov/design-tokens": "3.0.0-rc1", @@ -20,8 +20,10 @@ "@react-keycloak/web": "3.4.0", "@reduxjs/toolkit": "1.8.6", "@terraformer/wkt": "2.2.1", + "@tmcw/togeojson": "7.1.2", "@turf/turf": "7.0.0", "@types/polylabel": "1.0.5", + "@xmldom/xmldom": "0.8.11", "@xstate/react": "3.2.2", "axios": "1.6.7", "bootstrap": "4.6.2", @@ -34,6 +36,7 @@ "formik": "2.2.6", "is-absolute-url": "3.0.3", "js-file-download": "0.4.12", + "jszip": "3.10.1", "keycloak-js": "26.0.0", "leaflet": "1.9.2", "lodash": "4.17.21", @@ -93,6 +96,7 @@ "@types/eslint-plugin-prettier": "3.1.3", "@types/geojson": "7946.0.7", "@types/jsdom": "21.1.7", + "@types/jszip": "3.4.0", "@types/leaflet": "1.9.0", "@types/lodash": "4.14.168", "@types/mocha": "10.0.6", @@ -4124,6 +4128,15 @@ "react-dom": ">=16.8" } }, + "node_modules/@tmcw/togeojson": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@tmcw/togeojson/-/togeojson-7.1.2.tgz", + "integrity": "sha512-QKnFs9DAuqqBVj4d6c69tV1Dj2TspSBTqffivoN0YoBCVdP/JY1+WaYCJbzU49RkoU5NOSOJ3jtFHCdEUVh21A==", + "license": "BSD-2-Clause", + "engines": { + "node": "*" + } + }, "node_modules/@turf/along": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/@turf/along/-/along-7.0.0.tgz", @@ -6652,6 +6665,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jszip": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz", + "integrity": "sha512-GFHqtQQP3R4NNuvZH3hNCYD0NbyBZ42bkN7kO3NDrU/SnvIZWMS8Bp38XCsRKBT5BXvgm0y1zqpZWp/ZkRzBzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jszip": "*" + } + }, "node_modules/@types/leaflet": { "version": "1.9.0", "dev": true, @@ -7726,6 +7749,15 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/@xstate/react": { "version": "3.2.2", "license": "MIT", @@ -9668,7 +9700,6 @@ }, "node_modules/core-util-is": { "version": "1.0.3", - "dev": true, "license": "MIT" }, "node_modules/cosmiconfig": { @@ -13185,6 +13216,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immer": { "version": "9.0.21", "license": "MIT", @@ -13259,7 +13296,6 @@ }, "node_modules/inherits": { "version": "2.0.4", - "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -14769,6 +14805,54 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/kdbush": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", @@ -14853,6 +14937,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "dev": true, @@ -16119,7 +16212,6 @@ }, "node_modules/pako": { "version": "1.0.11", - "dev": true, "license": "(MIT AND Zlib)" }, "node_modules/parent-module": { @@ -16901,7 +16993,6 @@ }, "node_modules/process-nextick-args": { "version": "2.0.1", - "dev": true, "license": "MIT" }, "node_modules/proj4": { @@ -19056,7 +19147,6 @@ }, "node_modules/setimmediate": { "version": "1.0.5", - "dev": true, "license": "MIT" }, "node_modules/setprototypeof": { @@ -20562,7 +20652,6 @@ }, "node_modules/util-deprecate": { "version": "1.0.2", - "dev": true, "license": "MIT" }, "node_modules/util/node_modules/inherits": { diff --git a/source/frontend/package.json b/source/frontend/package.json index 16cb42b878..557385fa8f 100644 --- a/source/frontend/package.json +++ b/source/frontend/package.json @@ -15,8 +15,10 @@ "@react-keycloak/web": "3.4.0", "@reduxjs/toolkit": "1.8.6", "@terraformer/wkt": "2.2.1", + "@tmcw/togeojson": "7.1.2", "@turf/turf": "7.0.0", "@types/polylabel": "1.0.5", + "@xmldom/xmldom": "0.8.11", "@xstate/react": "3.2.2", "axios": "1.6.7", "bootstrap": "4.6.2", @@ -29,6 +31,7 @@ "formik": "2.2.6", "is-absolute-url": "3.0.3", "js-file-download": "0.4.12", + "jszip": "3.10.1", "keycloak-js": "26.0.0", "leaflet": "1.9.2", "lodash": "4.17.21", @@ -88,6 +91,7 @@ "@types/eslint-plugin-prettier": "3.1.3", "@types/geojson": "7946.0.7", "@types/jsdom": "21.1.7", + "@types/jszip": "3.4.0", "@types/leaflet": "1.9.0", "@types/lodash": "4.14.168", "@types/mocha": "10.0.6", diff --git a/source/frontend/src/features/properties/shapeUpload/ShapeUploadContainer.tsx b/source/frontend/src/features/properties/shapeUpload/ShapeUploadContainer.tsx index 8891dadfce..e22442c864 100644 --- a/source/frontend/src/features/properties/shapeUpload/ShapeUploadContainer.tsx +++ b/source/frontend/src/features/properties/shapeUpload/ShapeUploadContainer.tsx @@ -18,7 +18,8 @@ export interface IShapeUploadContainerProps { } /** - * Component that provides functionality to upload shapefiles. Can be embedded as a widget. + * Component that provides functionality to upload boundary files (Shapefile, KML, KMZ). + * Can be embedded as a widget. */ export const ShapeUploadContainer: React.FunctionComponent = ({ formikRef, diff --git a/source/frontend/src/features/properties/shapeUpload/ShapeUploadForm.test.tsx b/source/frontend/src/features/properties/shapeUpload/ShapeUploadForm.test.tsx index e6e0a31d86..4b69e793a8 100644 --- a/source/frontend/src/features/properties/shapeUpload/ShapeUploadForm.test.tsx +++ b/source/frontend/src/features/properties/shapeUpload/ShapeUploadForm.test.tsx @@ -74,7 +74,7 @@ describe('ShapeUploadForm', () => { }); expect( - screen.getByText(/You have attached a shapefile for property: property-456/i), + screen.getByText(/You have attached a boundary file for property: property-456/i), ).toBeInTheDocument(); }); @@ -88,7 +88,7 @@ describe('ShapeUploadForm', () => { }); expect( - screen.getByText(/You have attached a shapefile. Do you want to proceed and save/i), + screen.getByText(/You have attached a boundary file. Do you want to proceed and save/i), ).toBeInTheDocument(); }); diff --git a/source/frontend/src/features/properties/shapeUpload/ShapeUploadForm.tsx b/source/frontend/src/features/properties/shapeUpload/ShapeUploadForm.tsx index 3d52e71054..b02479aef8 100644 --- a/source/frontend/src/features/properties/shapeUpload/ShapeUploadForm.tsx +++ b/source/frontend/src/features/properties/shapeUpload/ShapeUploadForm.tsx @@ -17,7 +17,8 @@ export interface IShapeUploadFormProps { } /** - * Component that provides functionality to upload shapefiles. Can be embedded as a widget. + * Component that provides functionality to upload boundary files (Shapefile, KML, KMZ). + * Can be embedded as a widget. */ export const ShapeUploadForm: React.FunctionComponent = ({ isLoading, @@ -37,18 +38,21 @@ export const ShapeUploadForm: React.FunctionComponent = ( <> -
+
+ Accepted formats: Shapefile (.zip), KML (.kml), or KMZ (.kmz) +
{ if (files.length === 1) { formikProps.setFieldValue('file', firstOrNull(files)); } }} - validExtensions={['zip']} + validExtensions={['zip', 'kml', 'kmz']} multiple={false} keyName={formikProps.values?.file?.name} /> @@ -62,8 +66,8 @@ export const ShapeUploadForm: React.FunctionComponent = (
{exists(propertyIdentifier) - ? `You have attached a shapefile for property: ${propertyIdentifier}. Do you want to proceed and save?` - : 'You have attached a shapefile. Do you want to proceed and save?'} + ? `You have attached a boundary file for property: ${propertyIdentifier}. Do you want to proceed and save?` + : 'You have attached a boundary file. Do you want to proceed and save?'}
)} diff --git a/source/frontend/src/features/properties/shapeUpload/ShapeUploadModal.tsx b/source/frontend/src/features/properties/shapeUpload/ShapeUploadModal.tsx index bed5330f6f..892bd502ec 100644 --- a/source/frontend/src/features/properties/shapeUpload/ShapeUploadModal.tsx +++ b/source/frontend/src/features/properties/shapeUpload/ShapeUploadModal.tsx @@ -38,7 +38,7 @@ export const ShapeUploadModal: React.FunctionComponent = setUploadResult(result); }; - // Warn user if they are about to lose data when cancelling "Upload Shapefile" modal + // Warn user if they are about to lose data when cancelling "Upload Boundary File" modal const onCloseHandler = () => { const dirty = formikRef.current?.dirty ?? false; if (dirty && !displayConfirmation) { @@ -56,7 +56,7 @@ export const ShapeUploadModal: React.FunctionComponent = display={display} setDisplay={setDisplay} headerIcon={} - title="Upload Shapefile" + title="Upload boundary file" message={ { uploadResult.isSuccess = true; setup({ props: { uploadResult } }); - expect(screen.getByText(/Shapefile uploaded successfully/i)).toBeInTheDocument(); + expect(screen.getByText(/Boundary file uploaded successfully/i)).toBeInTheDocument(); expect(screen.getByText('example.zip')).toBeInTheDocument(); const icon = screen.getByTestId('file-check-icon'); expect(icon).toBeInTheDocument(); @@ -71,7 +71,7 @@ describe('ShapeUploadResultView', () => { uploadResult.errorMessage = 'Upload failed'; setup({ props: { uploadResult } }); - expect(screen.getByText(/Shapefile upload failed/i)).toBeInTheDocument(); + expect(screen.getByText(/Boundary file upload failed/i)).toBeInTheDocument(); expect(screen.getByText('example.zip')).toBeInTheDocument(); expect(screen.getByText('Upload failed')).toBeInTheDocument(); const icon = screen.getByTestId('file-error-icon'); diff --git a/source/frontend/src/features/properties/shapeUpload/ShapeUploadResultView.tsx b/source/frontend/src/features/properties/shapeUpload/ShapeUploadResultView.tsx index 778b3cc580..1cf23e714a 100644 --- a/source/frontend/src/features/properties/shapeUpload/ShapeUploadResultView.tsx +++ b/source/frontend/src/features/properties/shapeUpload/ShapeUploadResultView.tsx @@ -18,7 +18,7 @@ export const ShapeUploadResultView: React.FunctionComponent {uploadResult.isSuccess ? ( - + {truncate(uploadResult.fileName ?? '', { length: 100 })} ) : ( - + {truncate(uploadResult.fileName ?? '', { length: 100 })} matches snapshot 1`] = `
+ class="pt-2 pb-1 text-muted" + > + Accepted formats: Shapefile (.zip), KML (.kml), or KMZ (.kmz) +
matches snapshot 1`] = ` > Browse renders correctly 1`] = `
+ class="pt-2 pb-1 text-muted" + > + Accepted formats: Shapefile (.zip), KML (.kml), or KMZ (.kmz) +
renders correctly 1`] = ` > Browse matches snapshot for failure 1`] = `
matches snapshot for success 1`] = `
{ + // @xmldom/xmldom calls console.error internally when it encounters invalid XML, + // before populating the parsererror element. Suppress it globally for this suite + // so vitest-fail-on-console does not fail tests that intentionally pass bad XML. + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + describe('isKmz', () => { + it('returns true for a valid KMZ buffer', async () => { + const buffer = await makeFakeKmzBuffer(); + expect(await KmzHelper.isKmz(buffer)).toBe(true); + }); + + it('returns false when ZIP has no .kml entry', async () => { + const { default: JSZip } = await import('jszip'); + const zip = new JSZip(); + zip.file('other.txt', 'data'); + const buffer = await zip.generateAsync({ type: 'arraybuffer' }); + expect(await KmzHelper.isKmz(buffer)).toBe(false); + }); + + it('returns false when .kml content is not valid XML', async () => { + const buffer = await makeFakeKmzBuffer('<<>>'); + expect(await KmzHelper.isKmz(buffer)).toBe(false); + }); + + it('returns false when .kml has no root element', async () => { + const buffer = await makeFakeKmzBuffer(INVALID_KML_NO_ROOT); + expect(await KmzHelper.isKmz(buffer)).toBe(false); + }); + + it('returns false for a plain shapefile ZIP', async () => { + const buffer = await makeFakeShapefileBuffer(); + expect(await KmzHelper.isKmz(buffer)).toBe(false); + }); + }); + + describe('isKml', () => { + it('returns true for a valid KML buffer', async () => { + const buffer = makeKmlBuffer(); + expect(await KmzHelper.isKml(buffer)).toBe(true); + }); + + it('returns false for KML content without root', async () => { + const buffer = makeKmlBuffer(INVALID_KML_NO_ROOT); + expect(await KmzHelper.isKml(buffer)).toBe(false); + }); + + it('returns false for a non-XML buffer', async () => { + const buffer = new TextEncoder().encode('not xml at all <<<').buffer; + expect(await KmzHelper.isKml(buffer)).toBe(false); + }); + + it('returns false for a shapefile ZIP buffer', async () => { + const buffer = await makeFakeShapefileBuffer(); + expect(await KmzHelper.isKml(buffer)).toBe(false); + }); + }); + + describe('validate (KMZ)', () => { + it('resolves for a valid KMZ buffer', async () => { + const buffer = await makeFakeKmzBuffer(); + await expect(KmzHelper.validateKmz(buffer)).resolves.toBeUndefined(); + }); + + it('throws when ZIP has no .kml entry', async () => { + const { default: JSZip } = await import('jszip'); + const zip = new JSZip(); + zip.file('other.txt', 'data'); + const buffer = await zip.generateAsync({ type: 'arraybuffer' }); + await expect(KmzHelper.validateKmz(buffer)).rejects.toThrow( + 'Invalid KMZ: no .kml file found in ZIP.', + ); + }); + + it('throws when .kml XML has a parse error', async () => { + const buffer = await makeFakeKmzBuffer('<<>>'); + await expect(KmzHelper.validateKmz(buffer)).rejects.toThrow( + 'Invalid KML: file does not contain a root element.', + ); + }); + + it('throws when .kml has no root', async () => { + const buffer = await makeFakeKmzBuffer(INVALID_KML_NO_ROOT); + await expect(KmzHelper.validateKmz(buffer)).rejects.toThrow( + 'Invalid KML: file does not contain a root element.', + ); + }); + }); + + describe('validateKml', () => { + it('resolves for a valid KML buffer', async () => { + const buffer = makeKmlBuffer(); + await expect(KmzHelper.validateKml(buffer)).resolves.toBeUndefined(); + }); + + it('throws when KML has no root element', async () => { + const buffer = makeKmlBuffer(INVALID_KML_NO_ROOT); + await expect(KmzHelper.validateKml(buffer)).rejects.toThrow( + 'Invalid KML: file does not contain a root element.', + ); + }); + + it('throws when content is not valid XML', async () => { + const buffer = new TextEncoder().encode('<<>>').buffer; + await expect(KmzHelper.validateKml(buffer)).rejects.toThrow( + 'Invalid KML: file does not contain a root element.', + ); + }); + }); + + describe('toGeoJson', () => { + it('parses a valid KML buffer', async () => { + const buffer = makeKmlBuffer(); + const result = await KmzHelper.toGeoJson(buffer); + expect(result.type).toBe('FeatureCollection'); + expect(Array.isArray(result.features)).toBe(true); + }); + + it('parses a valid KMZ buffer', async () => { + const buffer = await makeFakeKmzBuffer(); + const result = await KmzHelper.toGeoJson(buffer); + expect(result.type).toBe('FeatureCollection'); + }); + + it('KMZ takes priority over KML detection', async () => { + const buffer = await makeFakeKmzBuffer(); + const isKmlSpy = vi.spyOn(KmzHelper, 'isKml'); + const isKmzSpy = vi.spyOn(KmzHelper, 'isKmz').mockResolvedValueOnce(true); + await KmzHelper.toGeoJson(buffer); + expect(isKmzSpy).toHaveBeenCalledWith(buffer); + expect(isKmlSpy).not.toHaveBeenCalled(); + isKmlSpy.mockRestore(); + isKmzSpy.mockRestore(); + }); + + it('throws when buffer is neither valid KML nor KMZ', async () => { + const buffer = new TextEncoder().encode('not a geo file').buffer; + await expect(KmzHelper.toGeoJson(buffer)).rejects.toThrow( + 'Failed to parse file. Please ensure the file is a valid KML or KMZ file.', + ); + }); + }); +}); diff --git a/source/frontend/src/features/properties/shapeUpload/helpers/KmzHelper.ts b/source/frontend/src/features/properties/shapeUpload/helpers/KmzHelper.ts new file mode 100644 index 0000000000..63a335d6e8 --- /dev/null +++ b/source/frontend/src/features/properties/shapeUpload/helpers/KmzHelper.ts @@ -0,0 +1,99 @@ +import { kml } from '@tmcw/togeojson'; +import { DOMParser } from '@xmldom/xmldom'; +import { FeatureCollection } from 'geojson'; + +import { exists } from '@/utils'; + +import { loadZipSafely } from './ZipSafetyValidator'; + +export class KmzHelper { + public static async isKmz(buffer: ArrayBuffer): Promise { + try { + await KmzHelper.validateKmz(buffer); + return true; + } catch { + return false; + } + } + + public static async isKml(buffer: ArrayBuffer): Promise { + try { + await KmzHelper.validateKml(buffer); + return true; + } catch { + return false; + } + } + + /** + * Finds a .kml entry in the ZIP file and validates it can be parsed as XML and contains a root element. + * @param buffer The ArrayBuffer of the KMZ file to validate. + */ + public static async validateKmz(buffer: ArrayBuffer): Promise { + const zip = await loadZipSafely(buffer); + const files = zip.files; + + const kmlEntry = Object.values(files).find(f => f.name.toLowerCase().endsWith('.kml')); + if (!exists(kmlEntry)) { + throw new Error('Invalid KMZ: no .kml file found in ZIP.'); + } + + const kmlText = await kmlEntry.async('text'); + KmzHelper.parseAndValidateKmlText(kmlText); + } + + /** + * Validates a KML file represented as an ArrayBuffer. + * @param buffer The ArrayBuffer of the KML file to validate. + */ + public static async validateKml(buffer: ArrayBuffer): Promise { + const kmlText = new TextDecoder().decode(buffer); + KmzHelper.parseAndValidateKmlText(kmlText); + } + + /** + * Converts a KML or KMZ file represented as an ArrayBuffer to a GeoJSON FeatureCollection. + * @param buffer The ArrayBuffer of the KML or KMZ file to convert. + * @returns A Promise resolving to the GeoJSON FeatureCollection. + */ + public static async toGeoJson(buffer: ArrayBuffer): Promise { + if (await KmzHelper.isKmz(buffer)) { + return KmzHelper.kmzToGeoJson(buffer); + } + if (await KmzHelper.isKml(buffer)) { + return KmzHelper.kmlToGeoJson(new TextDecoder().decode(buffer)); + } + throw new Error('Failed to parse file. Please ensure the file is a valid KML or KMZ file.'); + } + + private static parseAndValidateKmlText(kmlText: string): void { + const doc = new DOMParser().parseFromString(kmlText, 'text/xml'); + + if (doc.getElementsByTagName('parsererror').length > 0) { + throw new Error('Invalid KML: file could not be parsed as XML.'); + } + if (doc.getElementsByTagName('kml').length === 0) { + throw new Error('Invalid KML: file does not contain a root element.'); + } + } + + private static kmlToGeoJson(kmlText: string): FeatureCollection { + try { + const doc = new DOMParser().parseFromString(kmlText, 'text/xml'); + return kml(doc) as FeatureCollection; + } catch { + throw new Error('Failed to parse KML. Please ensure the file is a valid KML file.'); + } + } + + private static async kmzToGeoJson(buffer: ArrayBuffer): Promise { + try { + const zip = await loadZipSafely(buffer); + const kmlEntry = Object.values(zip.files).find(f => f.name.toLowerCase().endsWith('.kml')); + const kmlText = await kmlEntry.async('text'); + return KmzHelper.kmlToGeoJson(kmlText); + } catch { + throw new Error('Failed to parse KMZ. Please ensure the file is a valid KMZ file.'); + } + } +} diff --git a/source/frontend/src/features/properties/shapeUpload/helpers/ShapefileHelper.test.ts b/source/frontend/src/features/properties/shapeUpload/helpers/ShapefileHelper.test.ts new file mode 100644 index 0000000000..4ae7c5bd12 --- /dev/null +++ b/source/frontend/src/features/properties/shapeUpload/helpers/ShapefileHelper.test.ts @@ -0,0 +1,145 @@ +import { FeatureCollection } from 'geojson'; +import JSZip from 'jszip'; +import shp from 'shpjs'; + +import { + makeBadMagicShapefileBuffer, + makeFakeKmzBuffer, + makeFakeShapefileBuffer, + mockGeoJson, +} from '../models.fixtures'; +import { ShapefileHelper } from './ShapefileHelper'; + +vi.mock('shpjs'); + +describe('ShapefileHelper', () => { + describe('isShapefile', () => { + it('returns true for a valid shapefile ZIP', async () => { + const buffer = await makeFakeShapefileBuffer(); + expect(await ShapefileHelper.isShapefile(buffer)).toBe(true); + }); + + it('returns false when ZIP has no .shp entry', async () => { + const zip = new JSZip(); + zip.file('test.dbf', new ArrayBuffer(8)); + zip.file('test.shx', new ArrayBuffer(8)); + const buffer = await zip.generateAsync({ type: 'arraybuffer' }); + expect(await ShapefileHelper.isShapefile(buffer)).toBe(false); + }); + + it('returns false when .shp has wrong magic number', async () => { + const buffer = await makeBadMagicShapefileBuffer(); + expect(await ShapefileHelper.isShapefile(buffer)).toBe(false); + }); + + it('returns false when .shx is missing', async () => { + const zip = new JSZip(); + const shpHeader = new ArrayBuffer(8); + new DataView(shpHeader).setInt32(0, 9994, false); + zip.file('test.shp', shpHeader); + zip.file('test.dbf', new ArrayBuffer(8)); + const buffer = await zip.generateAsync({ type: 'arraybuffer' }); + expect(await ShapefileHelper.isShapefile(buffer)).toBe(false); + }); + + it('returns false when .dbf is missing', async () => { + const zip = new JSZip(); + const shpHeader = new ArrayBuffer(8); + new DataView(shpHeader).setInt32(0, 9994, false); + zip.file('test.shp', shpHeader); + zip.file('test.shx', new ArrayBuffer(8)); + const buffer = await zip.generateAsync({ type: 'arraybuffer' }); + expect(await ShapefileHelper.isShapefile(buffer)).toBe(false); + }); + + it('returns false for a KMZ buffer', async () => { + const buffer = await makeFakeKmzBuffer(); + expect(await ShapefileHelper.isShapefile(buffer)).toBe(false); + }); + }); + + describe('validate', () => { + it('resolves for a valid shapefile buffer', async () => { + const buffer = await makeFakeShapefileBuffer(); + await expect(ShapefileHelper.validate(buffer)).resolves.toBeUndefined(); + }); + + it('throws when .shp is missing', async () => { + const zip = new JSZip(); + zip.file('test.dbf', new ArrayBuffer(8)); + const buffer = await zip.generateAsync({ type: 'arraybuffer' }); + await expect(ShapefileHelper.validate(buffer)).rejects.toThrow( + 'Invalid shapefile: no .shp file found in ZIP.', + ); + }); + + it('throws when magic number is wrong', async () => { + const buffer = await makeBadMagicShapefileBuffer(); + await expect(ShapefileHelper.validate(buffer)).rejects.toThrow( + 'Invalid shapefile: unexpected .shp header', + ); + }); + + it('throws when .shx companion is missing', async () => { + const zip = new JSZip(); + const shpHeader = new ArrayBuffer(8); + new DataView(shpHeader).setInt32(0, 9994, false); + zip.file('test.shp', shpHeader); + zip.file('test.dbf', new ArrayBuffer(8)); + const buffer = await zip.generateAsync({ type: 'arraybuffer' }); + await expect(ShapefileHelper.validate(buffer)).rejects.toThrow( + 'Invalid shapefile: missing required .shx file.', + ); + }); + + it('throws when .dbf companion is missing', async () => { + const zip = new JSZip(); + const shpHeader = new ArrayBuffer(8); + new DataView(shpHeader).setInt32(0, 9994, false); + zip.file('test.shp', shpHeader); + zip.file('test.shx', new ArrayBuffer(8)); + const buffer = await zip.generateAsync({ type: 'arraybuffer' }); + await expect(ShapefileHelper.validate(buffer)).rejects.toThrow( + 'Invalid shapefile: missing required .dbf file.', + ); + }); + + it('validates companions case-insensitively', async () => { + const zip = new JSZip(); + const shpHeader = new ArrayBuffer(8); + new DataView(shpHeader).setInt32(0, 9994, false); + zip.file('TEST.SHP', shpHeader); + zip.file('TEST.SHX', new ArrayBuffer(8)); + zip.file('TEST.DBF', new ArrayBuffer(8)); + const buffer = await zip.generateAsync({ type: 'arraybuffer' }); + await expect(ShapefileHelper.validate(buffer)).resolves.toBeUndefined(); + }); + }); + + describe('toGeoJson', () => { + beforeEach(() => { + vi.mocked(shp).mockReset(); + }); + + it('returns parsed GeoJSON for a valid shapefile buffer', async () => { + vi.mocked(shp).mockResolvedValue(mockGeoJson); + const buffer = await makeFakeShapefileBuffer(); + const result = await ShapefileHelper.toGeoJson(buffer); + expect(result).toEqual(mockGeoJson); + }); + + it('unwraps first element when shpjs returns an array', async () => { + const secondGeoJson: FeatureCollection = { type: 'FeatureCollection', features: [] }; + vi.mocked(shp).mockResolvedValue([mockGeoJson, secondGeoJson]); + const buffer = await makeFakeShapefileBuffer(); + const result = await ShapefileHelper.toGeoJson(buffer); + expect(result).toEqual(mockGeoJson); + }); + + it('throws a friendly error when shpjs fails', async () => { + vi.mocked(shp).mockRejectedValue(new Error('parse error')); + const buffer = await makeFakeShapefileBuffer(); + await expect(ShapefileHelper.toGeoJson(buffer)).rejects.toThrow('Failed to parse shapefile.'); + }); + }); +}); diff --git a/source/frontend/src/features/properties/shapeUpload/helpers/ShapefileHelper.ts b/source/frontend/src/features/properties/shapeUpload/helpers/ShapefileHelper.ts new file mode 100644 index 0000000000..d03f86ed44 --- /dev/null +++ b/source/frontend/src/features/properties/shapeUpload/helpers/ShapefileHelper.ts @@ -0,0 +1,64 @@ +import { FeatureCollection } from 'geojson'; +import shp from 'shpjs'; + +import { exists, firstOrNull } from '@/utils'; + +import { loadZipSafely } from './ZipSafetyValidator'; + +export class ShapefileHelper { + /** + * Checks if the provided ArrayBuffer represents a valid shapefile. + * @param buffer The ArrayBuffer of the shapefile to validate. + * @returns true if the buffer is a valid shapefile (contains a .shp file with correct header and accompanying .shx and .dbf files), false otherwise. + */ + public static async isShapefile(buffer: ArrayBuffer): Promise { + try { + await ShapefileHelper.validate(buffer); + return true; + } catch { + return false; + } + } + + /** + * Find a .shp entry in the ZIP file and validate it has the correct file header and accompanying .shx and .dbf files. + * @param buffer The ArrayBuffer of the shapefile to validate. + */ + public static async validate(buffer: ArrayBuffer): Promise { + const zip = await loadZipSafely(buffer); + const files = zip.files; + + const shpEntry = Object.values(files).find(f => f.name.toLowerCase().endsWith('.shp')); + if (!exists(shpEntry)) { + throw new Error('Invalid shapefile: no .shp file found in ZIP.'); + } + + // Validate the .shp magic number (big-endian 9994 = 0x0000270A) - https://en.wikipedia.org/wiki/Shapefile + const shpBuffer = await shpEntry.async('arraybuffer'); + const magicNumber = new DataView(shpBuffer).getInt32(0, false); + if (magicNumber !== 9994) { + throw new Error( + `Invalid shapefile: unexpected .shp header (got ${magicNumber}, expected 9994).`, + ); + } + + const baseName = shpEntry.name.toLowerCase().replace(/\.shp$/, ''); + const allNames = Object.keys(files).map(f => f.toLowerCase()); + + if (!allNames.includes(`${baseName}.shx`)) { + throw new Error('Invalid shapefile: missing required .shx file.'); + } + if (!allNames.includes(`${baseName}.dbf`)) { + throw new Error('Invalid shapefile: missing required .dbf file.'); + } + } + + public static async toGeoJson(buffer: ArrayBuffer): Promise { + try { + const geojson = await shp(buffer); + return Array.isArray(geojson) ? firstOrNull(geojson) : geojson; + } catch { + throw new Error('Failed to parse shapefile. Please ensure the file is a valid shapefile.'); + } + } +} diff --git a/source/frontend/src/features/properties/shapeUpload/helpers/ZipSafetyValidator.test.ts b/source/frontend/src/features/properties/shapeUpload/helpers/ZipSafetyValidator.test.ts new file mode 100644 index 0000000000..1e48130957 --- /dev/null +++ b/source/frontend/src/features/properties/shapeUpload/helpers/ZipSafetyValidator.test.ts @@ -0,0 +1,77 @@ +import JSZip from 'jszip'; + +import { loadZipSafely } from './ZipSafetyValidator'; + +async function makeZipWithEntries(count: number, entrySize = 8): Promise { + const zip = new JSZip(); + for (let i = 0; i < count; i++) { + zip.file(`file${i}.txt`, new ArrayBuffer(entrySize)); + } + return zip.generateAsync({ type: 'arraybuffer' }); +} + +/** + * Builds a small real ZIP then patches _data.uncompressedSize on each file entry + * so the validator sees the desired size without allocating real memory. + */ +async function makeZipWithFakeUncompressedSize(uncompressedSize: number): Promise { + const zip = new JSZip(); + zip.file('file.bin', new ArrayBuffer(8)); + const buffer = await zip.generateAsync({ type: 'arraybuffer' }); + + // Patch JSZip.loadAsync to return a zip whose entry reports the fake uncompressed size + const realLoadAsync: typeof JSZip.loadAsync = JSZip.loadAsync.bind(JSZip); + + vi.spyOn(JSZip, 'loadAsync').mockImplementationOnce(async (...args) => { + const loaded = await realLoadAsync(...args); + Object.values(loaded.files) + .filter(f => !f.dir) + .forEach(f => ((f as any)._data = { uncompressedSize })); + return loaded; + }); + + return buffer; +} + +describe('loadZipSafely', () => { + it('loads a normal ZIP without throwing', async () => { + const buffer = await makeZipWithEntries(3); + await expect(loadZipSafely(buffer)).resolves.toBeDefined(); + }); + + it('throws when entry count exceeds the maximum', async () => { + const buffer = await makeZipWithEntries(101); + await expect(loadZipSafely(buffer)).rejects.toThrow( + 'Zip archive rejected: contains 101 entries', + ); + }); + + it('accepts an archive exactly at the entry limit', async () => { + const buffer = await makeZipWithEntries(100); + await expect(loadZipSafely(buffer)).resolves.toBeDefined(); + }); + + it('throws when uncompressed size exceeds the 50MB limit', async () => { + const buffer = await makeZipWithFakeUncompressedSize(51 * 1024 * 1024); + await expect(loadZipSafely(buffer)).rejects.toThrow( + 'Zip archive rejected: uncompressed size exceeds the 50MB limit.', + ); + }); + + it('throws when compression ratio exceeds the maximum', async () => { + const zip = new JSZip(); + // Highly compressible data: a long repeated string compresses far beyond 3:1 + const highlyCompressible = 'A'.repeat(1024 * 1024); + zip.file('compressible.txt', highlyCompressible); + const buffer = await zip.generateAsync({ type: 'arraybuffer', compression: 'DEFLATE' }); + await expect(loadZipSafely(buffer)).rejects.toThrow('Zip archive rejected: compression ratio'); + }); + + it('ignores directory entries when counting files', async () => { + const zip = new JSZip(); + zip.folder('subdir'); + zip.file('subdir/file.txt', new ArrayBuffer(8)); + const buffer = await zip.generateAsync({ type: 'arraybuffer' }); + await expect(loadZipSafely(buffer)).resolves.toBeDefined(); + }); +}); diff --git a/source/frontend/src/features/properties/shapeUpload/helpers/ZipSafetyValidator.ts b/source/frontend/src/features/properties/shapeUpload/helpers/ZipSafetyValidator.ts new file mode 100644 index 0000000000..3aac46b0fb --- /dev/null +++ b/source/frontend/src/features/properties/shapeUpload/helpers/ZipSafetyValidator.ts @@ -0,0 +1,56 @@ +import JSZip from 'jszip'; + +// Maximum ratio of uncompressed to compressed data (3:1 is typical for legitimate archives) +const MAX_COMPRESSION_RATIO = 3; +// Maximum total uncompressed size: 50MB +const MAX_UNCOMPRESSED_BYTES = 50 * 1024 * 1024; +// Maximum number of entries in the archive +const MAX_FILE_ENTRIES = 100; + +/** + * Loads a ZIP archive from an ArrayBuffer, rejecting it if it shows signs of being a zip bomb: + * - More than MAX_FILE_ENTRIES entries + * - Total uncompressed size exceeds MAX_UNCOMPRESSED_BYTES + * - Compression ratio exceeds MAX_COMPRESSION_RATIO + * + * @throws Error if the archive fails any of the zip bomb checks. + * @see https://en.wikipedia.org/wiki/Zip_bomb for more information. + */ +export async function loadZipSafely(buffer: ArrayBuffer): Promise { + const zip = await JSZip.loadAsync(buffer); + const entries = Object.values(zip.files).filter(f => !f.dir); + + if (entries.length > MAX_FILE_ENTRIES) { + throw new Error( + `Zip archive rejected: contains ${entries.length} entries (max ${MAX_FILE_ENTRIES}).`, + ); + } + + const compressedSize = buffer.byteLength; + let totalUncompressedSize = 0; + + for (const entry of entries) { + // JSZip exposes _data.uncompressedSize after loading + const uncompressedSize = (entry as any)._data?.uncompressedSize ?? 0; + totalUncompressedSize += uncompressedSize; + + if (totalUncompressedSize > MAX_UNCOMPRESSED_BYTES) { + throw new Error( + `Zip archive rejected: uncompressed size exceeds the ${ + MAX_UNCOMPRESSED_BYTES / 1024 / 1024 + }MB limit.`, + ); + } + } + + const compressionRatio = compressedSize > 0 ? totalUncompressedSize / compressedSize : 0; + if (compressedSize > 0 && compressionRatio > MAX_COMPRESSION_RATIO) { + throw new Error( + `Zip archive rejected: compression ratio ${compressionRatio.toFixed( + 1, + )} exceeds the maximum allowed ratio of ${MAX_COMPRESSION_RATIO}.`, + ); + } + + return zip; +} diff --git a/source/frontend/src/features/properties/shapeUpload/models.fixtures.ts b/source/frontend/src/features/properties/shapeUpload/models.fixtures.ts new file mode 100644 index 0000000000..7e17a9353f --- /dev/null +++ b/source/frontend/src/features/properties/shapeUpload/models.fixtures.ts @@ -0,0 +1,60 @@ +import { FeatureCollection } from 'geojson'; +import JSZip from 'jszip'; + +export const VALID_KML = ` + + + Test + -123.0,49.0,0 + +`; + +export const INVALID_KML_NO_ROOT = ``; + +export const mockGeoJson: FeatureCollection = { + type: 'FeatureCollection', + features: [], +}; + +/** + * Builds a valid shapefile ZIP buffer containing the required .shp, .shx, and .dbf entries. + * The .shp entry is written with the correct magic number (9994, big-endian). + */ +export async function makeFakeShapefileBuffer(baseName = 'test'): Promise { + const zip = new JSZip(); + const shpHeader = new ArrayBuffer(8); + new DataView(shpHeader).setInt32(0, 9994, false); + zip.file(`${baseName}.shp`, shpHeader); + zip.file(`${baseName}.shx`, new ArrayBuffer(8)); + zip.file(`${baseName}.dbf`, new ArrayBuffer(8)); + return zip.generateAsync({ type: 'arraybuffer' }); +} + +/** + * Builds a ZIP buffer that contains a .shp with an invalid magic number. + */ +export async function makeBadMagicShapefileBuffer(): Promise { + const zip = new JSZip(); + const shpHeader = new ArrayBuffer(8); + new DataView(shpHeader).setInt32(0, 1234, false); // wrong magic + zip.file('test.shp', shpHeader); + zip.file('test.shx', new ArrayBuffer(8)); + zip.file('test.dbf', new ArrayBuffer(8)); + return zip.generateAsync({ type: 'arraybuffer' }); +} + +/** + * Builds a valid KMZ buffer (ZIP containing a .kml entry). + */ +export async function makeFakeKmzBuffer(kmlContent = VALID_KML): Promise { + const zip = new JSZip(); + zip.file('doc.kml', kmlContent); + return zip.generateAsync({ type: 'arraybuffer' }); +} + +/** + * Encodes a KML string to an ArrayBuffer (as if the user uploaded a plain .kml file). + */ +export function makeKmlBuffer(kmlContent = VALID_KML): ArrayBuffer { + return new TextEncoder().encode(kmlContent).buffer; +} diff --git a/source/frontend/src/features/properties/shapeUpload/models.test.ts b/source/frontend/src/features/properties/shapeUpload/models.test.ts new file mode 100644 index 0000000000..ded9e253eb --- /dev/null +++ b/source/frontend/src/features/properties/shapeUpload/models.test.ts @@ -0,0 +1,97 @@ +import { getMockFile } from '@/utils/test-utils'; + +import { KmzHelper } from './helpers/KmzHelper'; +import { ShapefileHelper } from './helpers/ShapefileHelper'; +import { ShapeUploadModel, UploadResponseModel } from './models'; +import { + makeFakeKmzBuffer, + makeFakeShapefileBuffer, + makeKmlBuffer, + mockGeoJson, +} from './models.fixtures'; + +describe('ShapeUploadModel', () => { + let model: ShapeUploadModel; + + beforeEach(() => { + model = new ShapeUploadModel(); + vi.clearAllMocks(); + }); + + describe('toGeoJson', () => { + it('throws when no file is set', async () => { + await expect(model.toGeoJson()).rejects.toThrow('No file provided'); + }); + + it('delegates to ShapefileHelper when isShapefile returns true', async () => { + const buffer = await makeFakeShapefileBuffer(); + model.file = getMockFile(buffer, 'test.zip'); + + vi.spyOn(ShapefileHelper, 'isShapefile').mockResolvedValue(true); + const shapefileSpy = vi.spyOn(ShapefileHelper, 'toGeoJson').mockResolvedValue(mockGeoJson); + + const result = await model.toGeoJson(); + expect(shapefileSpy).toHaveBeenCalled(); + expect(result).toEqual(mockGeoJson); + }); + + it('delegates to KmzHelper when isKmz returns true', async () => { + const buffer = await makeFakeKmzBuffer(); + model.file = getMockFile(buffer, 'test.kmz'); + + vi.spyOn(ShapefileHelper, 'isShapefile').mockResolvedValue(false); + vi.spyOn(KmzHelper, 'isKml').mockResolvedValue(false); + vi.spyOn(KmzHelper, 'isKmz').mockResolvedValue(true); + const kmzSpy = vi.spyOn(KmzHelper, 'toGeoJson').mockResolvedValue(mockGeoJson); + + const result = await model.toGeoJson(); + expect(kmzSpy).toHaveBeenCalled(); + expect(result).toEqual(mockGeoJson); + }); + + it('delegates to KmzHelper when isKml returns true', async () => { + const buffer = makeKmlBuffer(); + model.file = getMockFile(buffer, 'test.kml'); + + vi.spyOn(ShapefileHelper, 'isShapefile').mockResolvedValue(false); + vi.spyOn(KmzHelper, 'isKml').mockResolvedValue(true); + const kmzSpy = vi.spyOn(KmzHelper, 'toGeoJson').mockResolvedValue(mockGeoJson); + + const result = await model.toGeoJson(); + expect(kmzSpy).toHaveBeenCalled(); + expect(result).toEqual(mockGeoJson); + }); + + it('throws when file matches neither shapefile nor KML/KMZ', async () => { + model.file = getMockFile(new ArrayBuffer(16), 'test.zip'); + + vi.spyOn(ShapefileHelper, 'isShapefile').mockResolvedValue(false); + vi.spyOn(KmzHelper, 'isKml').mockResolvedValue(false); + vi.spyOn(KmzHelper, 'isKmz').mockResolvedValue(false); + + await expect(model.toGeoJson()).rejects.toThrow('Unsupported file format'); + }); + + it('reads the file buffer exactly once and passes it to helpers', async () => { + const buffer = await makeFakeShapefileBuffer(); + const file = getMockFile(buffer, 'test.zip'); + model.file = file; + + vi.spyOn(ShapefileHelper, 'isShapefile').mockResolvedValue(true); + vi.spyOn(ShapefileHelper, 'toGeoJson').mockResolvedValue(mockGeoJson); + + await model.toGeoJson(); + expect(file.arrayBuffer).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe('UploadResponseModel', () => { + it('initialises with correct defaults', () => { + const model = new UploadResponseModel('test.zip'); + expect(model.fileName).toBe('test.zip'); + expect(model.isSuccess).toBe(false); + expect(model.errorMessage).toBeNull(); + expect(model.boundary).toBeNull(); + }); +}); diff --git a/source/frontend/src/features/properties/shapeUpload/models.ts b/source/frontend/src/features/properties/shapeUpload/models.ts index d3ff63fb9a..4e96f936de 100644 --- a/source/frontend/src/features/properties/shapeUpload/models.ts +++ b/source/frontend/src/features/properties/shapeUpload/models.ts @@ -1,7 +1,9 @@ import { FeatureCollection, MultiPolygon, Polygon } from 'geojson'; -import shp from 'shpjs'; -import { exists, firstOrNull } from '@/utils'; +import { exists } from '@/utils'; + +import { KmzHelper } from './helpers/KmzHelper'; +import { ShapefileHelper } from './helpers/ShapefileHelper'; export class ShapeUploadModel { public file: File | null; @@ -14,13 +16,20 @@ export class ShapeUploadModel { if (!exists(this.file)) { throw new Error('No file provided'); } - try { - const arrayBuffer = await this.file.arrayBuffer(); - const geojson = await shp(arrayBuffer); - return Array.isArray(geojson) ? firstOrNull(geojson) : geojson; - } catch (error) { - throw new Error('Failed to parse shapefile. Please ensure the file is a valid shapefile.'); + + const arrayBuffer = await this.file.arrayBuffer(); + + if (await ShapefileHelper.isShapefile(arrayBuffer)) { + return ShapefileHelper.toGeoJson(arrayBuffer); + } + + if ((await KmzHelper.isKml(arrayBuffer)) || (await KmzHelper.isKmz(arrayBuffer))) { + return KmzHelper.toGeoJson(arrayBuffer); } + + throw new Error( + 'Unsupported file format. Please upload a valid shapefile (.zip), KMZ, or KML file.', + ); } } diff --git a/source/frontend/src/utils/test-utils.tsx b/source/frontend/src/utils/test-utils.tsx index bdbca3c550..197672e1cb 100644 --- a/source/frontend/src/utils/test-utils.tsx +++ b/source/frontend/src/utils/test-utils.tsx @@ -403,6 +403,16 @@ export function getMockRepositoryObj(response: T | undefined = undefine }; } +/** + * jsdom does not implement File.arrayBuffer(). This helper creates a File and + * stubs arrayBuffer() to return the provided buffer, matching production behaviour. + */ +export function getMockFile(buffer: ArrayBuffer, name: string, options?: FilePropertyBag): File { + const file = new File([buffer], name, options); + file.arrayBuffer = vi.fn().mockResolvedValue(buffer); + return file; +} + // re-export everything from RTL export * from '@testing-library/react'; export { default as userEvent } from '@testing-library/user-event';