Split logic into models file and add tests

This commit is contained in:
lionel-rowe 2024-06-08 14:40:50 +08:00
parent 04fdd7d2fc
commit 40fec6a3b5
No known key found for this signature in database
3 changed files with 160 additions and 108 deletions

View file

@ -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<T>(iter: AsyncIterable<T>) {
const out: T[] = [];
for await (const val of iter) {
out.push(val);
}
return out;
}
function checkProgressAndGetResult<T>(updates: Update<T>[]) {
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);
});
});
});

View file

@ -0,0 +1,93 @@
export type Update<Result> =
| {
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<Param, Result> = (
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<Param, Result>(
fn: BcryptFn<Param, Result>,
args: [string, Param],
options?: Partial<BcryptWithProgressOptions>,
): AsyncGenerator<Update<Result>, undefined, undefined> {
const { controller = new AbortController(), timeoutMs = 10_000 } = options ?? {};
let res = (_: Update<Result>) => {};
const nextPromise = () =>
new Promise<Update<Result>>((resolve) => {
res = resolve;
});
const promises = [nextPromise()];
const nextValue = (value: Update<Result>) => {
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;
}
}
}

View file

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { compare, hash } from 'bcryptjs'; import { compare, hash } from 'bcryptjs';
import { useThemeVars } from 'naive-ui'; import { useThemeVars } from 'naive-ui';
import { type BcryptFn, InvalidatedError, bcryptWithProgressUpdates } from './bcrypt.models';
import { useCopy } from '@/composable/copy'; import { useCopy } from '@/composable/copy';
const themeVars = useThemeVars(); const themeVars = useThemeVars();
@ -14,120 +15,30 @@ interface ExecutionState<T> {
const blankState = () => ({ result: null, percentage: 0, error: null, timeTakenMs: null }); const blankState = () => ({ result: null, percentage: 0, error: null, timeTakenMs: null });
type Update<Result> =
| {
kind: 'progress'
progress: number
}
| {
kind: 'result'
result: Result
timeTakenMs: number
}
| {
kind: 'error'
message: string
};
const TIMEOUT_SECONDS = 10;
class TimedOutError extends Error {
name = 'TimedOutError';
}
class InvalidatedError extends Error {
name = 'InvalidatedError';
}
// generic type for the callback versions of bcryptjs's `hash` and `compare`
type BcryptFn<Param, Result> = (
arg1: string,
arg2: Param,
callback: (err: Error | null, hash: Result) => void,
progressCallback: (percent: number) => void,
) => void;
async function* runWithProgress<Param, Result>(
fn: BcryptFn<Param, Result>,
arg1: string,
arg2: Param,
controller: AbortController,
): AsyncGenerator<Update<Result>, undefined, undefined> {
let res = (_: Update<Result>) => {};
const nextPromise = () =>
new Promise<Update<Result>>((resolve) => {
res = resolve;
});
const promises = [nextPromise()];
const nextValue = (value: Update<Result>) => {
res(value);
promises.push(nextPromise());
};
const start = Date.now();
fn(
arg1,
arg2,
(err, result) => {
nextValue(
err == null
? { kind: 'result', result, 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 ${TIMEOUT_SECONDS} seconds`));
}, TIMEOUT_SECONDS * 1000);
for await (const value of promises) {
yield value;
if (value.kind === 'result' || value.kind === 'error') {
return;
}
}
}
async function exec<Param, Result>( async function exec<Param, Result>(
fn: BcryptFn<Param, Result>, fn: BcryptFn<Param, Result>,
arg1: string | null, args: [string | null, Param | null],
arg2: Param | null,
controller: AbortController, controller: AbortController,
state: ExecutionState<Result>, state: ExecutionState<Result>,
) { ) {
if (arg1 == null || arg2 == null) { const [arg0, arg1] = args;
if (arg0 == null || arg1 == null) {
return; return;
} }
for await (const value of runWithProgress(fn, arg1, arg2, controller)) { for await (const update of bcryptWithProgressUpdates(fn, [arg0, arg1], { controller, timeoutMs: 10_000 })) {
switch (value.kind) { switch (update.kind) {
case 'progress': { case 'progress': {
state.percentage = value.progress * 100; state.percentage = Math.round(update.progress * 100);
break; break;
} }
case 'result': { case 'success': {
state.result = value.result; state.result = update.value;
state.timeTakenMs = value.timeTakenMs; state.timeTakenMs = update.timeTakenMs;
break; break;
} }
case 'error': { case 'error': {
state.error = value.message; state.error = update.message;
break; break;
} }
} }
@ -136,23 +47,22 @@ async function exec<Param, Result>(
function initWatcher<Param, Result>( function initWatcher<Param, Result>(
fn: BcryptFn<Param, Result>, fn: BcryptFn<Param, Result>,
input1: Ref<string | null>, inputs: [Ref<string | null>, Ref<Param | null>],
input2: Ref<Param | null>,
state: Ref<ExecutionState<Result>>, state: Ref<ExecutionState<Result>>,
) { ) {
let controller = new AbortController(); let controller = new AbortController();
watch([input1, input2], ([input1, input2]) => { watch(inputs, (inputs) => {
controller.abort(new InvalidatedError()); controller.abort(new InvalidatedError());
controller = new AbortController(); controller = new AbortController();
state.value = blankState(); state.value = blankState();
exec(fn, input1, input2, controller, state.value); exec(fn, inputs, controller, state.value);
}); });
} }
const hashState = ref<ExecutionState<string>>(blankState()); const hashState = ref<ExecutionState<string>>(blankState());
const input = ref(''); const input = ref('');
const saltCount = ref(10); const saltCount = ref(10);
initWatcher(hash, input, saltCount, hashState); initWatcher(hash, [input, saltCount], hashState);
const source = computed(() => hashState.value.result ?? ''); const source = computed(() => hashState.value.result ?? '');
const { copy } = useCopy({ source, text: 'Hashed string copied to the clipboard' }); const { copy } = useCopy({ source, text: 'Hashed string copied to the clipboard' });
@ -160,7 +70,7 @@ const { copy } = useCopy({ source, text: 'Hashed string copied to the clipboard'
const compareState = ref<ExecutionState<boolean>>(blankState()); const compareState = ref<ExecutionState<boolean>>(blankState());
const compareString = ref(''); const compareString = ref('');
const compareHash = ref(''); const compareHash = ref('');
initWatcher(compare, compareString, compareHash, compareState); initWatcher(compare, [compareString, compareHash], compareState);
</script> </script>
<template> <template>
@ -187,7 +97,7 @@ initWatcher(compare, compareString, compareHash, compareState);
text-center text-center
/> />
<div mt-1 h-3 op-60> <div mt-1 h-3 op-60>
{{ hashState.timeTakenMs == null ? '' : `Hashed in ${hashState.timeTakenMs}\xa0ms` }} {{ hashState.timeTakenMs == null ? '' : `Hashed in ${hashState.timeTakenMs}\xA0ms` }}
</div> </div>
<div mt-5 flex justify-center> <div mt-5 flex justify-center>
@ -219,7 +129,7 @@ initWatcher(compare, compareString, compareHash, compareState);
/> />
</div> </div>
<div mb-1 mt-1 h-3 op-60> <div mb-1 mt-1 h-3 op-60>
{{ compareState.timeTakenMs == null ? '' : `Compared in ${compareState.timeTakenMs}\xa0ms` }} {{ compareState.timeTakenMs == null ? '' : `Compared in ${compareState.timeTakenMs}\xA0ms` }}
</div> </div>
</n-form> </n-form>
</c-card> </c-card>