diff --git a/src/tools/bcrypt/bcrypt.models.test.ts b/src/tools/bcrypt/bcrypt.models.test.ts new file mode 100644 index 00000000..7d933410 --- /dev/null +++ b/src/tools/bcrypt/bcrypt.models.test.ts @@ -0,0 +1,49 @@ +import { compare, hash } from 'bcryptjs'; +import { assert, describe, expect, test } from 'vitest'; +import { type Update, bcryptWithProgressUpdates } from './bcrypt.models'; + +// simplified polyfill for https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/fromAsync +async function fromAsync(iter: AsyncIterable) { + const out: T[] = []; + for await (const val of iter) { + out.push(val); + } + return out; +} + +function checkProgressAndGetResult(updates: Update[]) { + const first = updates.at(0); + const penultimate = updates.at(-2); + const last = updates.at(-1); + const allExceptLast = updates.slice(0, -1); + + expect(allExceptLast.every(x => x.kind === 'progress')).toBeTruthy(); + expect(first).toEqual({ kind: 'progress', progress: 0 }); + expect(penultimate).toEqual({ kind: 'progress', progress: 1 }); + + assert(last != null && last.kind === 'success'); + + return last; +} + +describe('bcrypt models', () => { + describe(bcryptWithProgressUpdates.name, () => { + test('with bcrypt hash function', async () => { + const updates = await fromAsync(bcryptWithProgressUpdates(hash, ['abc', 5])); + const result = checkProgressAndGetResult(updates); + + expect(result.value).toMatch(/^\$2a\$05\$.{53}$/); + expect(result.timeTakenMs).toBeGreaterThan(0); + }); + + test('with bcrypt compare function', async () => { + const updates = await fromAsync( + bcryptWithProgressUpdates(compare, ['abc', '$2a$05$FHzYelm8Qn.IhGP.N8V1TOWFlRTK.8cphbxZSvSFo9B6HGscnQdhy']), + ); + const result = checkProgressAndGetResult(updates); + + expect(result.value).toBe(true); + expect(result.timeTakenMs).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/tools/bcrypt/bcrypt.models.ts b/src/tools/bcrypt/bcrypt.models.ts new file mode 100644 index 00000000..02c5eb95 --- /dev/null +++ b/src/tools/bcrypt/bcrypt.models.ts @@ -0,0 +1,93 @@ +export type Update = + | { + kind: 'progress' + progress: number + } + | { + kind: 'success' + value: Result + timeTakenMs: number + } + | { + kind: 'error' + message: string + }; + +export class TimedOutError extends Error { + name = 'TimedOutError'; +} +export class InvalidatedError extends Error { + name = 'InvalidatedError'; +} + +// generic type for the callback versions of bcryptjs's `hash` and `compare` +export type BcryptFn = ( + arg1: string, + arg2: Param, + callback: (err: Error | null, hash: Result) => void, + progressCallback: (percent: number) => void, +) => void; + +interface BcryptWithProgressOptions { + controller: AbortController + timeoutMs: number +} + +export async function* bcryptWithProgressUpdates( + fn: BcryptFn, + args: [string, Param], + options?: Partial, +): AsyncGenerator, undefined, undefined> { + const { controller = new AbortController(), timeoutMs = 10_000 } = options ?? {}; + + let res = (_: Update) => {}; + const nextPromise = () => + new Promise>((resolve) => { + res = resolve; + }); + const promises = [nextPromise()]; + const nextValue = (value: Update) => { + res(value); + promises.push(nextPromise()); + }; + + const start = Date.now(); + + fn( + args[0], + args[1], + (err, value) => { + nextValue( + err == null + ? { kind: 'success', value, timeTakenMs: Date.now() - start } + : { kind: 'error', message: err.message }, + ); + }, + (progress) => { + if (controller.signal.aborted) { + nextValue({ kind: 'progress', progress: 0 }); + if (controller.signal.reason instanceof TimedOutError) { + nextValue({ kind: 'error', message: controller.signal.reason.message }); + } + + // throw inside callback to cancel execution of hashing/comparing + throw controller.signal.reason; + } + else { + nextValue({ kind: 'progress', progress }); + } + }, + ); + + setTimeout(() => { + controller.abort(new TimedOutError(`Timed out after ${(timeoutMs / 1000).toLocaleString('en-US')}\xA0seconds`)); + }, timeoutMs); + + for await (const value of promises) { + yield value; + + if (value.kind === 'success' || value.kind === 'error') { + return; + } + } +} diff --git a/src/tools/bcrypt/bcrypt.vue b/src/tools/bcrypt/bcrypt.vue index c795f0ff..75be82b6 100644 --- a/src/tools/bcrypt/bcrypt.vue +++ b/src/tools/bcrypt/bcrypt.vue @@ -1,6 +1,7 @@