From 23892defe396d835fba8fe4e55394f3a2d900d81 Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Mon, 9 Mar 2026 16:38:08 -0700 Subject: [PATCH 01/11] New dependencies to support kmz file handling --- source/frontend/package-lock.json | 105 +++++++++++++++++++++++++++--- source/frontend/package.json | 4 ++ 2 files changed, 101 insertions(+), 8 deletions(-) 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", From 9be74574b8dcdbb00f7f81e0072764766d8af73c Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Mon, 9 Mar 2026 21:28:53 -0700 Subject: [PATCH 02/11] Add shapefile validation and handling using JSZip --- .../features/properties/shapeUpload/models.ts | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/source/frontend/src/features/properties/shapeUpload/models.ts b/source/frontend/src/features/properties/shapeUpload/models.ts index d3ff63fb9a..8c5243dae5 100644 --- a/source/frontend/src/features/properties/shapeUpload/models.ts +++ b/source/frontend/src/features/properties/shapeUpload/models.ts @@ -1,4 +1,5 @@ import { FeatureCollection, MultiPolygon, Polygon } from 'geojson'; +import JSZip from 'jszip'; import shp from 'shpjs'; import { exists, firstOrNull } from '@/utils'; @@ -10,12 +11,59 @@ export class ShapeUploadModel { this.file = file; } + public async isShapefile(): Promise { + try { + await this.validateShapefile(); + return true; + } catch { + return false; + } + } + + public async validateShapefile(buffer?: ArrayBuffer): Promise { + if (!exists(this.file) && !exists(buffer)) { + throw new Error('No file or buffer provided'); + } + + const arrayBuffer = buffer ?? (await this.file.arrayBuffer()); + const zip = await JSZip.loadAsync(arrayBuffer); + const files = zip.files; + + // Find a .shp entry in the ZIP file and validate it has the correct file header and accompanying .shx and .dbf 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 async toGeoJson(): Promise { if (!exists(this.file)) { throw new Error('No file provided'); } + + const arrayBuffer = await this.file.arrayBuffer(); + await this.validateShapefile(arrayBuffer); + try { - const arrayBuffer = await this.file.arrayBuffer(); const geojson = await shp(arrayBuffer); return Array.isArray(geojson) ? firstOrNull(geojson) : geojson; } catch (error) { From 1110dff14ffb6ebce1a95828056da845d2abedfd Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Tue, 10 Mar 2026 14:01:09 -0700 Subject: [PATCH 03/11] Refactor shape upload model to utilize ShapefileHelper and KmzHelper for file validation and conversion --- .../properties/shapeUpload/helpers.ts | 117 ++++++++++++++++++ .../features/properties/shapeUpload/models.ts | 62 ++-------- 2 files changed, 126 insertions(+), 53 deletions(-) create mode 100644 source/frontend/src/features/properties/shapeUpload/helpers.ts diff --git a/source/frontend/src/features/properties/shapeUpload/helpers.ts b/source/frontend/src/features/properties/shapeUpload/helpers.ts new file mode 100644 index 0000000000..4f75fe7395 --- /dev/null +++ b/source/frontend/src/features/properties/shapeUpload/helpers.ts @@ -0,0 +1,117 @@ +import { kml } from '@tmcw/togeojson'; +import { DOMParser } from '@xmldom/xmldom'; +import { FeatureCollection } from 'geojson'; +import JSZip from 'jszip'; +import shp from 'shpjs'; + +import { exists, firstOrNull } from '@/utils'; + +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 JSZip.loadAsync(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.'); + } + } +} + +export class KmzHelper { + /** + * Checks if the provided ArrayBuffer represents a valid KMZ file. + * @param buffer The ArrayBuffer of the KMZ file to validate. + * @returns true if the buffer is a valid KMZ file, false otherwise. + */ + public static async isKmz(buffer: ArrayBuffer): Promise { + try { + await KmzHelper.validate(buffer); + return true; + } catch { + return false; + } + } + + /** + * Find a .kml entry in the ZIP file and validate it can be parsed as XML and contains a root element. + * @param buffer The ArrayBuffer of the KMZ file to validate. + */ + public static async validate(buffer: ArrayBuffer): Promise { + const zip = await JSZip.loadAsync(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'); + const doc = new DOMParser().parseFromString(kmlText, 'text/xml'); + + if (doc.getElementsByTagName('parsererror').length > 0) { + throw new Error('Invalid KMZ: .kml file could not be parsed as XML.'); + } + if (doc.getElementsByTagName('kml').length === 0) { + throw new Error('Invalid KMZ: .kml file does not contain a root element.'); + } + } + + public static async toGeoJson(buffer: ArrayBuffer): Promise { + try { + const zip = await JSZip.loadAsync(buffer); + const kmlEntry = Object.values(zip.files).find(f => f.name.toLowerCase().endsWith('.kml')); + const kmlText = await kmlEntry.async('text'); + const doc = new DOMParser().parseFromString(kmlText, 'text/xml'); + return kml(doc) as FeatureCollection; + } 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/models.ts b/source/frontend/src/features/properties/shapeUpload/models.ts index 8c5243dae5..95ee3bb420 100644 --- a/source/frontend/src/features/properties/shapeUpload/models.ts +++ b/source/frontend/src/features/properties/shapeUpload/models.ts @@ -1,8 +1,8 @@ import { FeatureCollection, MultiPolygon, Polygon } from 'geojson'; -import JSZip from 'jszip'; -import shp from 'shpjs'; -import { exists, firstOrNull } from '@/utils'; +import { exists } from '@/utils'; + +import { KmzHelper, ShapefileHelper } from './helpers'; export class ShapeUploadModel { public file: File | null; @@ -11,63 +11,19 @@ export class ShapeUploadModel { this.file = file; } - public async isShapefile(): Promise { - try { - await this.validateShapefile(); - return true; - } catch { - return false; - } - } - - public async validateShapefile(buffer?: ArrayBuffer): Promise { - if (!exists(this.file) && !exists(buffer)) { - throw new Error('No file or buffer provided'); - } - - const arrayBuffer = buffer ?? (await this.file.arrayBuffer()); - const zip = await JSZip.loadAsync(arrayBuffer); - const files = zip.files; - - // Find a .shp entry in the ZIP file and validate it has the correct file header and accompanying .shx and .dbf 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 async toGeoJson(): Promise { if (!exists(this.file)) { throw new Error('No file provided'); } const arrayBuffer = await this.file.arrayBuffer(); - await this.validateShapefile(arrayBuffer); - try { - 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.'); + if (await ShapefileHelper.isShapefile(arrayBuffer)) { + return ShapefileHelper.toGeoJson(arrayBuffer); + } + + if (await KmzHelper.isKmz(arrayBuffer)) { + return KmzHelper.toGeoJson(arrayBuffer); } } } From 9ba311c37e489c62ad71e5ab10c9f4ff517e9d18 Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Tue, 10 Mar 2026 17:03:28 -0700 Subject: [PATCH 04/11] Refactor KmzHelper to improve KMZ and KML validation and conversion methods --- .../properties/shapeUpload/helpers.ts | 66 +++++++++++++++---- .../features/properties/shapeUpload/models.ts | 6 +- 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/source/frontend/src/features/properties/shapeUpload/helpers.ts b/source/frontend/src/features/properties/shapeUpload/helpers.ts index 4f75fe7395..a271e166ae 100644 --- a/source/frontend/src/features/properties/shapeUpload/helpers.ts +++ b/source/frontend/src/features/properties/shapeUpload/helpers.ts @@ -65,14 +65,18 @@ export class ShapefileHelper { } export class KmzHelper { - /** - * Checks if the provided ArrayBuffer represents a valid KMZ file. - * @param buffer The ArrayBuffer of the KMZ file to validate. - * @returns true if the buffer is a valid KMZ file, false otherwise. - */ public static async isKmz(buffer: ArrayBuffer): Promise { try { - await KmzHelper.validate(buffer); + 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; @@ -80,10 +84,10 @@ export class KmzHelper { } /** - * Find a .kml entry in the ZIP file and validate it can be parsed as XML and contains a root element. + * 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 validate(buffer: ArrayBuffer): Promise { + public static async validateKmz(buffer: ArrayBuffer): Promise { const zip = await JSZip.loadAsync(buffer); const files = zip.files; @@ -93,23 +97,59 @@ export class KmzHelper { } 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.isKml(buffer)) { + return KmzHelper.kmlToGeoJson(new TextDecoder().decode(buffer)); + } + if (await KmzHelper.isKmz(buffer)) { + return KmzHelper.kmzToGeoJson(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 KMZ: .kml file could not be parsed as XML.'); + throw new Error('Invalid KML: file could not be parsed as XML.'); } if (doc.getElementsByTagName('kml').length === 0) { - throw new Error('Invalid KMZ: .kml file does not contain a root element.'); + throw new Error('Invalid KML: file does not contain a root element.'); } } - public static async toGeoJson(buffer: ArrayBuffer): Promise { + 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 JSZip.loadAsync(buffer); const kmlEntry = Object.values(zip.files).find(f => f.name.toLowerCase().endsWith('.kml')); const kmlText = await kmlEntry.async('text'); - const doc = new DOMParser().parseFromString(kmlText, 'text/xml'); - return kml(doc) as FeatureCollection; + 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/models.ts b/source/frontend/src/features/properties/shapeUpload/models.ts index 95ee3bb420..296f442b12 100644 --- a/source/frontend/src/features/properties/shapeUpload/models.ts +++ b/source/frontend/src/features/properties/shapeUpload/models.ts @@ -22,9 +22,13 @@ export class ShapeUploadModel { return ShapefileHelper.toGeoJson(arrayBuffer); } - if (await KmzHelper.isKmz(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.', + ); } } From 56ab6f904a3e4af47bf6afaaee21d46380a85045 Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Tue, 10 Mar 2026 20:59:25 -0700 Subject: [PATCH 05/11] Update ShapeUpload components to reflect boundary file terminology and enhance user guidance --- .../shapeUpload/ShapeUploadContainer.tsx | 3 ++- .../properties/shapeUpload/ShapeUploadForm.tsx | 16 ++++++++++------ .../properties/shapeUpload/ShapeUploadModal.tsx | 2 +- .../shapeUpload/ShapeUploadResultView.tsx | 4 ++-- 4 files changed, 15 insertions(+), 10 deletions(-) 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.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..cc1dcee8b5 100644 --- a/source/frontend/src/features/properties/shapeUpload/ShapeUploadModal.tsx +++ b/source/frontend/src/features/properties/shapeUpload/ShapeUploadModal.tsx @@ -56,7 +56,7 @@ export const ShapeUploadModal: React.FunctionComponent = display={display} setDisplay={setDisplay} headerIcon={} - title="Upload Shapefile" + title="Upload boundary file" message={ {uploadResult.isSuccess ? ( - + {truncate(uploadResult.fileName ?? '', { length: 100 })} ) : ( - + {truncate(uploadResult.fileName ?? '', { length: 100 })} Date: Tue, 10 Mar 2026 21:21:42 -0700 Subject: [PATCH 06/11] Add unit tests for ShapefileHelper and KmzHelper functionality --- .../properties/shapeUpload/models.test.ts | 442 ++++++++++++++++++ 1 file changed, 442 insertions(+) create mode 100644 source/frontend/src/features/properties/shapeUpload/models.test.ts 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..7b05aa5cdf --- /dev/null +++ b/source/frontend/src/features/properties/shapeUpload/models.test.ts @@ -0,0 +1,442 @@ +import { FeatureCollection } from 'geojson'; +import JSZip from 'jszip'; +import shp from 'shpjs'; + +import { KmzHelper, ShapefileHelper } from './helpers'; +import { ShapeUploadModel, UploadResponseModel } from './models'; + +vi.mock('shpjs'); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * 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). + */ +async function makeFakeShapefileBuffer(baseName = 'test'): Promise { + const zip = new JSZip(); + + // .shp with valid magic number (9994 big-endian = 0x0000270A) + 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)); + + const blob = await zip.generateAsync({ type: 'arraybuffer' }); + return blob; +} + +/** + * Builds a ZIP buffer that contains a .shp with an invalid magic number. + */ +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' }); +} + +const VALID_KML = ` + + + Test + -123.0,49.0,0 + +`; + +const INVALID_KML_NO_ROOT = ``; + +/** + * Builds a valid KMZ buffer (ZIP containing a .kml entry). + */ +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). + */ +function makeKmlBuffer(kmlContent = VALID_KML): ArrayBuffer { + return new TextEncoder().encode(kmlContent).buffer; +} + +const mockGeoJson: FeatureCollection = { + type: 'FeatureCollection', + features: [], +}; + +// ── ShapefileHelper ─────────────────────────────────────────────────────────── + +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.'); + }); + }); +}); + +// ── KmzHelper ───────────────────────────────────────────────────────────────── + +describe('KmzHelper', () => { + 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 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('not xml'); + 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 KMZ (ZIP) buffer', async () => { + const buffer = await makeFakeKmzBuffer(); + // A KMZ is a ZIP, so decoding as text won't produce valid KML XML + 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 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('not xml'); + await expect(KmzHelper.validateKmz(buffer)).rejects.toThrow( + 'Invalid KML: file could not be parsed as XML.', + ); + }); + + 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('bad').buffer; + await expect(KmzHelper.validateKml(buffer)).rejects.toThrow( + 'Invalid KML: file could not be parsed as XML.', + ); + }); + }); + + 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('KML takes priority over KMZ detection', async () => { + // A plain KML buffer should resolve via the KML path, not fall through to KMZ + const buffer = makeKmlBuffer(); + const isKmlSpy = vi.spyOn(KmzHelper, 'isKml').mockResolvedValueOnce(true); + const isKmzSpy = vi.spyOn(KmzHelper, 'isKmz'); + await KmzHelper.toGeoJson(buffer); + expect(isKmlSpy).toHaveBeenCalledWith(buffer); + expect(isKmzSpy).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.', + ); + }); + }); +}); + +// ── ShapeUploadModel ────────────────────────────────────────────────────────── + +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(); + const file = new File([buffer], 'test.zip'); + model.file = file; + + 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(); + const file = new File([buffer], 'test.kmz'); + model.file = file; + + 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(); + const file = new File([buffer], 'test.kml'); + model.file = file; + + 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 () => { + const file = new File([new ArrayBuffer(16)], 'test.zip'); + model.file = file; + + 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 = new File([buffer], 'test.zip'); + model.file = file; + + const arrayBufferSpy = vi.spyOn(file, 'arrayBuffer'); + vi.spyOn(ShapefileHelper, 'isShapefile').mockResolvedValue(true); + vi.spyOn(ShapefileHelper, 'toGeoJson').mockResolvedValue(mockGeoJson); + + await model.toGeoJson(); + expect(arrayBufferSpy).toHaveBeenCalledTimes(1); + }); + }); +}); + +// ── UploadResponseModel ─────────────────────────────────────────────────────── + +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(); + }); +}); From 90cb3959c63c53907d6c8f876bcc0b087c5ab24d Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Wed, 11 Mar 2026 12:51:52 -0700 Subject: [PATCH 07/11] Refactor helper classes into their own files --- .../{helpers.ts => helpers/KmzHelper.ts} | 61 +----------------- .../shapeUpload/helpers/ShapefileHelper.ts | 63 +++++++++++++++++++ .../features/properties/shapeUpload/models.ts | 3 +- 3 files changed, 66 insertions(+), 61 deletions(-) rename source/frontend/src/features/properties/shapeUpload/{helpers.ts => helpers/KmzHelper.ts} (59%) create mode 100644 source/frontend/src/features/properties/shapeUpload/helpers/ShapefileHelper.ts diff --git a/source/frontend/src/features/properties/shapeUpload/helpers.ts b/source/frontend/src/features/properties/shapeUpload/helpers/KmzHelper.ts similarity index 59% rename from source/frontend/src/features/properties/shapeUpload/helpers.ts rename to source/frontend/src/features/properties/shapeUpload/helpers/KmzHelper.ts index a271e166ae..a6e3f55a98 100644 --- a/source/frontend/src/features/properties/shapeUpload/helpers.ts +++ b/source/frontend/src/features/properties/shapeUpload/helpers/KmzHelper.ts @@ -2,67 +2,8 @@ import { kml } from '@tmcw/togeojson'; import { DOMParser } from '@xmldom/xmldom'; import { FeatureCollection } from 'geojson'; import JSZip from 'jszip'; -import shp from 'shpjs'; -import { exists, firstOrNull } from '@/utils'; - -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 JSZip.loadAsync(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.'); - } - } -} +import { exists } from '@/utils'; export class KmzHelper { public static async isKmz(buffer: ArrayBuffer): Promise { 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..54fc4f8e0a --- /dev/null +++ b/source/frontend/src/features/properties/shapeUpload/helpers/ShapefileHelper.ts @@ -0,0 +1,63 @@ +import { FeatureCollection } from 'geojson'; +import JSZip from 'jszip'; +import shp from 'shpjs'; + +import { exists, firstOrNull } from '@/utils'; + +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 JSZip.loadAsync(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/models.ts b/source/frontend/src/features/properties/shapeUpload/models.ts index 296f442b12..4e96f936de 100644 --- a/source/frontend/src/features/properties/shapeUpload/models.ts +++ b/source/frontend/src/features/properties/shapeUpload/models.ts @@ -2,7 +2,8 @@ import { FeatureCollection, MultiPolygon, Polygon } from 'geojson'; import { exists } from '@/utils'; -import { KmzHelper, ShapefileHelper } from './helpers'; +import { KmzHelper } from './helpers/KmzHelper'; +import { ShapefileHelper } from './helpers/ShapefileHelper'; export class ShapeUploadModel { public file: File | null; From ee19563f16b87843f0ef11047c3447666f5c689e Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Wed, 11 Mar 2026 14:49:35 -0700 Subject: [PATCH 08/11] Test updates --- .../shapeUpload/ShapeUploadModal.tsx | 2 +- .../shapeUpload/helpers/KmzHelper.test.ts | 154 +++++++ .../shapeUpload/helpers/KmzHelper.ts | 6 +- .../helpers/ShapefileHelper.test.ts | 145 +++++++ .../properties/shapeUpload/models.fixtures.ts | 60 +++ .../properties/shapeUpload/models.test.ts | 375 +----------------- source/frontend/src/utils/test-utils.tsx | 10 + 7 files changed, 388 insertions(+), 364 deletions(-) create mode 100644 source/frontend/src/features/properties/shapeUpload/helpers/KmzHelper.test.ts create mode 100644 source/frontend/src/features/properties/shapeUpload/helpers/ShapefileHelper.test.ts create mode 100644 source/frontend/src/features/properties/shapeUpload/models.fixtures.ts diff --git a/source/frontend/src/features/properties/shapeUpload/ShapeUploadModal.tsx b/source/frontend/src/features/properties/shapeUpload/ShapeUploadModal.tsx index cc1dcee8b5..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) { diff --git a/source/frontend/src/features/properties/shapeUpload/helpers/KmzHelper.test.ts b/source/frontend/src/features/properties/shapeUpload/helpers/KmzHelper.test.ts new file mode 100644 index 0000000000..76ca921dc5 --- /dev/null +++ b/source/frontend/src/features/properties/shapeUpload/helpers/KmzHelper.test.ts @@ -0,0 +1,154 @@ +import { + INVALID_KML_NO_ROOT, + makeFakeKmzBuffer, + makeFakeShapefileBuffer, + makeKmlBuffer, +} from '../models.fixtures'; +import { KmzHelper } from './KmzHelper'; + +describe('KmzHelper', () => { + // @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 index a6e3f55a98..468d69ca56 100644 --- a/source/frontend/src/features/properties/shapeUpload/helpers/KmzHelper.ts +++ b/source/frontend/src/features/properties/shapeUpload/helpers/KmzHelper.ts @@ -56,12 +56,12 @@ export class KmzHelper { * @returns A Promise resolving to the GeoJSON FeatureCollection. */ public static async toGeoJson(buffer: ArrayBuffer): Promise { - if (await KmzHelper.isKml(buffer)) { - return KmzHelper.kmlToGeoJson(new TextDecoder().decode(buffer)); - } 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.'); } 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/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 index 7b05aa5cdf..ded9e253eb 100644 --- a/source/frontend/src/features/properties/shapeUpload/models.test.ts +++ b/source/frontend/src/features/properties/shapeUpload/models.test.ts @@ -1,352 +1,14 @@ -import { FeatureCollection } from 'geojson'; -import JSZip from 'jszip'; -import shp from 'shpjs'; +import { getMockFile } from '@/utils/test-utils'; -import { KmzHelper, ShapefileHelper } from './helpers'; +import { KmzHelper } from './helpers/KmzHelper'; +import { ShapefileHelper } from './helpers/ShapefileHelper'; import { ShapeUploadModel, UploadResponseModel } from './models'; - -vi.mock('shpjs'); - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -/** - * 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). - */ -async function makeFakeShapefileBuffer(baseName = 'test'): Promise { - const zip = new JSZip(); - - // .shp with valid magic number (9994 big-endian = 0x0000270A) - 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)); - - const blob = await zip.generateAsync({ type: 'arraybuffer' }); - return blob; -} - -/** - * Builds a ZIP buffer that contains a .shp with an invalid magic number. - */ -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' }); -} - -const VALID_KML = ` - - - Test - -123.0,49.0,0 - -`; - -const INVALID_KML_NO_ROOT = ``; - -/** - * Builds a valid KMZ buffer (ZIP containing a .kml entry). - */ -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). - */ -function makeKmlBuffer(kmlContent = VALID_KML): ArrayBuffer { - return new TextEncoder().encode(kmlContent).buffer; -} - -const mockGeoJson: FeatureCollection = { - type: 'FeatureCollection', - features: [], -}; - -// ── ShapefileHelper ─────────────────────────────────────────────────────────── - -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.'); - }); - }); -}); - -// ── KmzHelper ───────────────────────────────────────────────────────────────── - -describe('KmzHelper', () => { - 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 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('not xml'); - 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 KMZ (ZIP) buffer', async () => { - const buffer = await makeFakeKmzBuffer(); - // A KMZ is a ZIP, so decoding as text won't produce valid KML XML - 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 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('not xml'); - await expect(KmzHelper.validateKmz(buffer)).rejects.toThrow( - 'Invalid KML: file could not be parsed as XML.', - ); - }); - - 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('bad').buffer; - await expect(KmzHelper.validateKml(buffer)).rejects.toThrow( - 'Invalid KML: file could not be parsed as XML.', - ); - }); - }); - - 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('KML takes priority over KMZ detection', async () => { - // A plain KML buffer should resolve via the KML path, not fall through to KMZ - const buffer = makeKmlBuffer(); - const isKmlSpy = vi.spyOn(KmzHelper, 'isKml').mockResolvedValueOnce(true); - const isKmzSpy = vi.spyOn(KmzHelper, 'isKmz'); - await KmzHelper.toGeoJson(buffer); - expect(isKmlSpy).toHaveBeenCalledWith(buffer); - expect(isKmzSpy).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.', - ); - }); - }); -}); - -// ── ShapeUploadModel ────────────────────────────────────────────────────────── +import { + makeFakeKmzBuffer, + makeFakeShapefileBuffer, + makeKmlBuffer, + mockGeoJson, +} from './models.fixtures'; describe('ShapeUploadModel', () => { let model: ShapeUploadModel; @@ -363,8 +25,7 @@ describe('ShapeUploadModel', () => { it('delegates to ShapefileHelper when isShapefile returns true', async () => { const buffer = await makeFakeShapefileBuffer(); - const file = new File([buffer], 'test.zip'); - model.file = file; + model.file = getMockFile(buffer, 'test.zip'); vi.spyOn(ShapefileHelper, 'isShapefile').mockResolvedValue(true); const shapefileSpy = vi.spyOn(ShapefileHelper, 'toGeoJson').mockResolvedValue(mockGeoJson); @@ -376,8 +37,7 @@ describe('ShapeUploadModel', () => { it('delegates to KmzHelper when isKmz returns true', async () => { const buffer = await makeFakeKmzBuffer(); - const file = new File([buffer], 'test.kmz'); - model.file = file; + model.file = getMockFile(buffer, 'test.kmz'); vi.spyOn(ShapefileHelper, 'isShapefile').mockResolvedValue(false); vi.spyOn(KmzHelper, 'isKml').mockResolvedValue(false); @@ -391,8 +51,7 @@ describe('ShapeUploadModel', () => { it('delegates to KmzHelper when isKml returns true', async () => { const buffer = makeKmlBuffer(); - const file = new File([buffer], 'test.kml'); - model.file = file; + model.file = getMockFile(buffer, 'test.kml'); vi.spyOn(ShapefileHelper, 'isShapefile').mockResolvedValue(false); vi.spyOn(KmzHelper, 'isKml').mockResolvedValue(true); @@ -404,8 +63,7 @@ describe('ShapeUploadModel', () => { }); it('throws when file matches neither shapefile nor KML/KMZ', async () => { - const file = new File([new ArrayBuffer(16)], 'test.zip'); - model.file = file; + model.file = getMockFile(new ArrayBuffer(16), 'test.zip'); vi.spyOn(ShapefileHelper, 'isShapefile').mockResolvedValue(false); vi.spyOn(KmzHelper, 'isKml').mockResolvedValue(false); @@ -416,21 +74,18 @@ describe('ShapeUploadModel', () => { it('reads the file buffer exactly once and passes it to helpers', async () => { const buffer = await makeFakeShapefileBuffer(); - const file = new File([buffer], 'test.zip'); + const file = getMockFile(buffer, 'test.zip'); model.file = file; - const arrayBufferSpy = vi.spyOn(file, 'arrayBuffer'); vi.spyOn(ShapefileHelper, 'isShapefile').mockResolvedValue(true); vi.spyOn(ShapefileHelper, 'toGeoJson').mockResolvedValue(mockGeoJson); await model.toGeoJson(); - expect(arrayBufferSpy).toHaveBeenCalledTimes(1); + expect(file.arrayBuffer).toHaveBeenCalledTimes(1); }); }); }); -// ── UploadResponseModel ─────────────────────────────────────────────────────── - describe('UploadResponseModel', () => { it('initialises with correct defaults', () => { const model = new UploadResponseModel('test.zip'); 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'; From d01fa5f656446392a644d9bc8f1cd29359bc9707 Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Wed, 11 Mar 2026 16:33:16 -0700 Subject: [PATCH 09/11] Update snapshots --- .../ShapeUploadForm.test.tsx.snap | 32 ++++++++++++++--- .../ShapeUploadModal.test.tsx.snap | 34 ++++++++++++++++--- .../ShapeUploadResultView.test.tsx.snap | 4 +-- 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/source/frontend/src/features/properties/shapeUpload/__snapshots__/ShapeUploadForm.test.tsx.snap b/source/frontend/src/features/properties/shapeUpload/__snapshots__/ShapeUploadForm.test.tsx.snap index 704998c123..d55bfa802b 100644 --- a/source/frontend/src/features/properties/shapeUpload/__snapshots__/ShapeUploadForm.test.tsx.snap +++ b/source/frontend/src/features/properties/shapeUpload/__snapshots__/ShapeUploadForm.test.tsx.snap @@ -57,15 +57,39 @@ exports[`ShapeUploadForm > 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`] = `
Date: Wed, 11 Mar 2026 16:37:13 -0700 Subject: [PATCH 10/11] Fix failing tests --- .../features/properties/shapeUpload/ShapeUploadForm.test.tsx | 4 ++-- .../properties/shapeUpload/ShapeUploadResultView.test.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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/ShapeUploadResultView.test.tsx b/source/frontend/src/features/properties/shapeUpload/ShapeUploadResultView.test.tsx index 7559f5a4be..f3c5f118a4 100644 --- a/source/frontend/src/features/properties/shapeUpload/ShapeUploadResultView.test.tsx +++ b/source/frontend/src/features/properties/shapeUpload/ShapeUploadResultView.test.tsx @@ -58,7 +58,7 @@ describe('ShapeUploadResultView', () => { 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'); From 6ec532d62f230f547e995cbdc21374dcfef7538e Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Thu, 12 Mar 2026 16:35:25 -0700 Subject: [PATCH 11/11] Implement ZipSafetyValidator for secure ZIP loading and update KmzHelper and ShapefileHelper to use it --- .../shapeUpload/helpers/KmzHelper.ts | 7 +- .../shapeUpload/helpers/ShapefileHelper.ts | 5 +- .../helpers/ZipSafetyValidator.test.ts | 77 +++++++++++++++++++ .../shapeUpload/helpers/ZipSafetyValidator.ts | 56 ++++++++++++++ 4 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 source/frontend/src/features/properties/shapeUpload/helpers/ZipSafetyValidator.test.ts create mode 100644 source/frontend/src/features/properties/shapeUpload/helpers/ZipSafetyValidator.ts diff --git a/source/frontend/src/features/properties/shapeUpload/helpers/KmzHelper.ts b/source/frontend/src/features/properties/shapeUpload/helpers/KmzHelper.ts index 468d69ca56..63a335d6e8 100644 --- a/source/frontend/src/features/properties/shapeUpload/helpers/KmzHelper.ts +++ b/source/frontend/src/features/properties/shapeUpload/helpers/KmzHelper.ts @@ -1,10 +1,11 @@ import { kml } from '@tmcw/togeojson'; import { DOMParser } from '@xmldom/xmldom'; import { FeatureCollection } from 'geojson'; -import JSZip from 'jszip'; import { exists } from '@/utils'; +import { loadZipSafely } from './ZipSafetyValidator'; + export class KmzHelper { public static async isKmz(buffer: ArrayBuffer): Promise { try { @@ -29,7 +30,7 @@ export class KmzHelper { * @param buffer The ArrayBuffer of the KMZ file to validate. */ public static async validateKmz(buffer: ArrayBuffer): Promise { - const zip = await JSZip.loadAsync(buffer); + const zip = await loadZipSafely(buffer); const files = zip.files; const kmlEntry = Object.values(files).find(f => f.name.toLowerCase().endsWith('.kml')); @@ -87,7 +88,7 @@ export class KmzHelper { private static async kmzToGeoJson(buffer: ArrayBuffer): Promise { try { - const zip = await JSZip.loadAsync(buffer); + 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); diff --git a/source/frontend/src/features/properties/shapeUpload/helpers/ShapefileHelper.ts b/source/frontend/src/features/properties/shapeUpload/helpers/ShapefileHelper.ts index 54fc4f8e0a..d03f86ed44 100644 --- a/source/frontend/src/features/properties/shapeUpload/helpers/ShapefileHelper.ts +++ b/source/frontend/src/features/properties/shapeUpload/helpers/ShapefileHelper.ts @@ -1,9 +1,10 @@ import { FeatureCollection } from 'geojson'; -import JSZip from 'jszip'; 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. @@ -24,7 +25,7 @@ export class ShapefileHelper { * @param buffer The ArrayBuffer of the shapefile to validate. */ public static async validate(buffer: ArrayBuffer): Promise { - const zip = await JSZip.loadAsync(buffer); + const zip = await loadZipSafely(buffer); const files = zip.files; const shpEntry = Object.values(files).find(f => f.name.toLowerCase().endsWith('.shp')); 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; +}