mirror of
https://github.com/CorentinTh/it-tools.git
synced 2025-05-04 21:37:11 -04:00
Split logic into models file and add tests
This commit is contained in:
parent
04fdd7d2fc
commit
40fec6a3b5
3 changed files with 160 additions and 108 deletions
49
src/tools/bcrypt/bcrypt.models.test.ts
Normal file
49
src/tools/bcrypt/bcrypt.models.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
93
src/tools/bcrypt/bcrypt.models.ts
Normal file
93
src/tools/bcrypt/bcrypt.models.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue