-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #388 from bcgov/DAP-1016-checksum-check
DAP-1016: Checksum validation function
- Loading branch information
Showing
3 changed files
with
236 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
import crypto from "node:crypto"; | ||
import yauzl from "yauzl"; | ||
import { HttpError, HTTP_STATUS_CODES } from "@bcgov/citz-imb-express-utilities"; | ||
|
||
type Props = { | ||
buffer: Buffer; | ||
checksum: string; | ||
filePath?: string; | ||
}; | ||
|
||
/** | ||
* Validates a buffer and checksum. If a filePath is provided, validates the checksum | ||
* against the file within the buffer if it's a valid zip archive. | ||
* | ||
* @param props - Object containing buffer, checksum, and optional filePath. | ||
* @returns True if checksum matches; otherwise, false. | ||
* @throws HttpError if the buffer is not a valid zip file or validation fails. | ||
*/ | ||
export const isChecksumValid = async ({ buffer, checksum, filePath }: Props): Promise<boolean> => { | ||
const calculateChecksum = (data: Buffer): string => { | ||
const hash = crypto.createHash("sha256"); | ||
hash.update(data); | ||
return hash.digest("hex"); | ||
}; | ||
|
||
if (filePath) { | ||
// Create a promise to validate the zip | ||
return new Promise<boolean>((resolve, reject) => { | ||
yauzl.fromBuffer(buffer, { lazyEntries: true }, (err, zipFile) => { | ||
if (err) { | ||
return reject( | ||
new HttpError( | ||
HTTP_STATUS_CODES.BAD_REQUEST, | ||
"Provided buffer is not a valid zip file.", | ||
), | ||
); | ||
} | ||
|
||
zipFile.on("entry", (entry) => { | ||
if (entry.fileName === filePath) { | ||
zipFile.openReadStream(entry, (err, readStream) => { | ||
if (err || !readStream) { | ||
return reject( | ||
new HttpError( | ||
HTTP_STATUS_CODES.BAD_REQUEST, | ||
`Error reading file at path "${filePath}".`, | ||
), | ||
); | ||
} | ||
|
||
const chunks: Buffer[] = []; | ||
readStream.on("data", (chunk) => chunks.push(chunk)); | ||
readStream.on("end", () => { | ||
const fileBuffer = Buffer.concat(chunks); | ||
const fileChecksum = calculateChecksum(fileBuffer); | ||
|
||
if (fileChecksum === checksum) { | ||
resolve(true); | ||
} else { | ||
reject(false); | ||
} | ||
zipFile.close(); | ||
}); | ||
}); | ||
} else { | ||
zipFile.readEntry(); | ||
} | ||
}); | ||
|
||
zipFile.on("end", () => { | ||
reject( | ||
new HttpError( | ||
HTTP_STATUS_CODES.NOT_FOUND, | ||
`File at path "${filePath}" not found in the zip buffer.`, | ||
), | ||
); | ||
}); | ||
|
||
zipFile.readEntry(); | ||
}); | ||
}); | ||
} | ||
|
||
// If no filePath is provided, validate the checksum of the entire buffer | ||
const bufferChecksum = calculateChecksum(buffer); | ||
if (bufferChecksum === checksum) return true; | ||
return false; | ||
}; |
147 changes: 147 additions & 0 deletions
147
backend/tests/modules/transfer/utils/isChecksumValid.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,147 @@ | ||
import { Readable } from "node:stream"; | ||
import crypto from "node:crypto"; | ||
import yauzl from "yauzl"; | ||
import { isChecksumValid } from "@/modules/transfer/utils/isChecksumValid"; | ||
import { HttpError, HTTP_STATUS_CODES } from "@bcgov/citz-imb-express-utilities"; | ||
|
||
jest.mock("yauzl"); | ||
|
||
const filePath = "path/inside/zip.txt"; | ||
const zipBuffer = Buffer.from("fake-zip-content"); // Mock buffer for zip testing | ||
const validChecksum = crypto.createHash("sha256").update("valid-content").digest("hex"); | ||
const invalidChecksum = "invalid-checksum"; | ||
|
||
interface MockZipFile extends Partial<yauzl.ZipFile> { | ||
entryCallback?: ((entry: yauzl.Entry) => void) | undefined; | ||
endCallback?: (() => void) | undefined; | ||
} | ||
|
||
// Mock yauzl.ZipFile behavior | ||
const mockZipFile: MockZipFile = { | ||
entryCallback: undefined, | ||
endCallback: undefined, | ||
on: jest.fn().mockImplementation(function (this: MockZipFile, event, callback) { | ||
if (event === "entry") { | ||
this.entryCallback = callback; | ||
} else if (event === "end") { | ||
this.endCallback = callback; | ||
} | ||
}), | ||
readEntry: jest.fn().mockImplementation(function (this: MockZipFile) { | ||
// Simulate triggering the `entry` event only once | ||
if (this.entryCallback) { | ||
const entryCallback = this.entryCallback; | ||
this.entryCallback = undefined; // Prevent re-triggering | ||
entryCallback({ fileName: filePath } as yauzl.Entry); | ||
} else if (this.endCallback) { | ||
// Trigger the `end` event after all entries have been processed | ||
const endCallback = this.endCallback; | ||
this.endCallback = undefined; // Prevent re-triggering | ||
endCallback(); | ||
} | ||
}), | ||
openReadStream: jest.fn( | ||
( | ||
entry: yauzl.Entry, | ||
optionsOrCallback: yauzl.ZipFileOptions | ((err: Error | null, stream: Readable) => void), | ||
callback?: (err: Error | null, stream: Readable) => void, | ||
) => { | ||
const cb = typeof optionsOrCallback === "function" ? optionsOrCallback : callback; | ||
if (!cb) return; | ||
|
||
if (entry.fileName === filePath) { | ||
const stream = Readable.from([Buffer.from("valid-content")]); | ||
cb(null, stream); | ||
} else { | ||
cb(new Error("File not found"), undefined as unknown as Readable); | ||
} | ||
}, | ||
), | ||
close: jest.fn(), | ||
}; | ||
|
||
describe("isChecksumValid", () => { | ||
beforeEach(() => { | ||
jest.clearAllMocks(); | ||
(yauzl.fromBuffer as jest.Mock).mockImplementation((buffer, options, callback) => { | ||
callback(null, mockZipFile as yauzl.ZipFile); | ||
}); | ||
}); | ||
|
||
// Test case: Valid checksum for a buffer | ||
it("should return true for a valid checksum for the entire buffer", async () => { | ||
const buffer = Buffer.from("valid-content"); | ||
const result = await isChecksumValid({ buffer, checksum: validChecksum }); | ||
expect(result).toBe(true); | ||
}); | ||
|
||
// Test case: Invalid checksum for a buffer | ||
it("should return false for an invalid checksum for the entire buffer", async () => { | ||
const buffer = Buffer.from("valid-content"); | ||
const result = await isChecksumValid({ buffer, checksum: invalidChecksum }); | ||
expect(result).toBe(false); | ||
}); | ||
|
||
// Test case: Valid checksum for a file inside the zip | ||
it("should return true for a valid checksum for a file inside the zip", async () => { | ||
const result = await isChecksumValid({ buffer: zipBuffer, checksum: validChecksum, filePath }); | ||
expect(result).toBe(true); | ||
}); | ||
|
||
// Test case: File not found in the zip | ||
it("should throw an HttpError if the file is not found in the zip", async () => { | ||
const nonExistentPath = "nonexistent/file.txt"; | ||
|
||
await expect( | ||
isChecksumValid({ | ||
buffer: zipBuffer, | ||
checksum: validChecksum, | ||
filePath: nonExistentPath, | ||
}), | ||
).rejects.toThrow( | ||
new HttpError( | ||
HTTP_STATUS_CODES.NOT_FOUND, | ||
`File at path "${nonExistentPath}" not found in the zip buffer.`, | ||
), | ||
); | ||
}); | ||
|
||
// Test case: Invalid zip buffer | ||
it("should throw an HttpError for an invalid zip buffer", async () => { | ||
(yauzl.fromBuffer as jest.Mock).mockImplementation((buffer, options, callback) => { | ||
callback(new Error("Invalid zip"), null); | ||
}); | ||
|
||
await expect( | ||
isChecksumValid({ | ||
buffer: Buffer.from("invalid-content"), | ||
checksum: validChecksum, | ||
filePath, | ||
}), | ||
).rejects.toThrow( | ||
new HttpError(HTTP_STATUS_CODES.BAD_REQUEST, "Provided buffer is not a valid zip file."), | ||
); | ||
}); | ||
|
||
// Test case: Error reading a file inside the zip | ||
it("should throw an HttpError if there is an error reading a file in the zip", async () => { | ||
(mockZipFile.openReadStream as jest.Mock).mockImplementationOnce( | ||
( | ||
entry: yauzl.Entry, | ||
optionsOrCallback: | ||
| yauzl.ZipFileOptions | ||
| ((err: Error | null, stream: Readable | null) => void), | ||
callback?: (err: Error | null, stream: Readable | null) => void, | ||
) => { | ||
const cb = typeof optionsOrCallback === "function" ? optionsOrCallback : callback; | ||
if (cb) cb(new Error("Stream error"), null); | ||
}, | ||
); | ||
|
||
await expect( | ||
isChecksumValid({ buffer: zipBuffer, checksum: validChecksum, filePath }), | ||
).rejects.toThrow( | ||
new HttpError(HTTP_STATUS_CODES.BAD_REQUEST, `Error reading file at path "${filePath}".`), | ||
); | ||
}); | ||
}); |