Split logic into models file and add tests
This commit is contained in:
		
							parent
							
								
									04fdd7d2fc
								
							
						
					
					
						commit
						40fec6a3b5
					
				
							
								
								
									
										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"> | ||||
| import { compare, hash } from 'bcryptjs'; | ||||
| import { useThemeVars } from 'naive-ui'; | ||||
| import { type BcryptFn, InvalidatedError, bcryptWithProgressUpdates } from './bcrypt.models'; | ||||
| import { useCopy } from '@/composable/copy'; | ||||
| 
 | ||||
| const themeVars = useThemeVars(); | ||||
| @ -14,120 +15,30 @@ interface ExecutionState<T> { | ||||
| 
 | ||||
| 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>( | ||||
|   fn: BcryptFn<Param, Result>, | ||||
|   arg1: string | null, | ||||
|   arg2: Param | null, | ||||
|   args: [string | null, Param | null], | ||||
|   controller: AbortController, | ||||
|   state: ExecutionState<Result>, | ||||
| ) { | ||||
|   if (arg1 == null || arg2 == null) { | ||||
|   const [arg0, arg1] = args; | ||||
|   if (arg0 == null || arg1 == null) { | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   for await (const value of runWithProgress(fn, arg1, arg2, controller)) { | ||||
|     switch (value.kind) { | ||||
|   for await (const update of bcryptWithProgressUpdates(fn, [arg0, arg1], { controller, timeoutMs: 10_000 })) { | ||||
|     switch (update.kind) { | ||||
|       case 'progress': { | ||||
|         state.percentage = value.progress * 100; | ||||
|         state.percentage = Math.round(update.progress * 100); | ||||
|         break; | ||||
|       } | ||||
|       case 'result': { | ||||
|         state.result = value.result; | ||||
|         state.timeTakenMs = value.timeTakenMs; | ||||
|       case 'success': { | ||||
|         state.result = update.value; | ||||
|         state.timeTakenMs = update.timeTakenMs; | ||||
|         break; | ||||
|       } | ||||
|       case 'error': { | ||||
|         state.error = value.message; | ||||
|         state.error = update.message; | ||||
|         break; | ||||
|       } | ||||
|     } | ||||
| @ -136,23 +47,22 @@ async function exec<Param, Result>( | ||||
| 
 | ||||
| function initWatcher<Param, Result>( | ||||
|   fn: BcryptFn<Param, Result>, | ||||
|   input1: Ref<string | null>, | ||||
|   input2: Ref<Param | null>, | ||||
|   inputs: [Ref<string | null>, Ref<Param | null>], | ||||
|   state: Ref<ExecutionState<Result>>, | ||||
| ) { | ||||
|   let controller = new AbortController(); | ||||
|   watch([input1, input2], ([input1, input2]) => { | ||||
|   watch(inputs, (inputs) => { | ||||
|     controller.abort(new InvalidatedError()); | ||||
|     controller = new AbortController(); | ||||
|     state.value = blankState(); | ||||
|     exec(fn, input1, input2, controller, state.value); | ||||
|     exec(fn, inputs, controller, state.value); | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| const hashState = ref<ExecutionState<string>>(blankState()); | ||||
| const input = ref(''); | ||||
| const saltCount = ref(10); | ||||
| initWatcher(hash, input, saltCount, hashState); | ||||
| initWatcher(hash, [input, saltCount], hashState); | ||||
| 
 | ||||
| const source = computed(() => hashState.value.result ?? ''); | ||||
| 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 compareString = ref(''); | ||||
| const compareHash = ref(''); | ||||
| initWatcher(compare, compareString, compareHash, compareState); | ||||
| initWatcher(compare, [compareString, compareHash], compareState); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
| @ -187,7 +97,7 @@ initWatcher(compare, compareString, compareHash, compareState); | ||||
|       text-center | ||||
|     /> | ||||
|     <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 mt-5 flex justify-center> | ||||
| @ -219,7 +129,7 @@ initWatcher(compare, compareString, compareHash, compareState); | ||||
|         /> | ||||
|       </div> | ||||
|       <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> | ||||
|     </n-form> | ||||
|   </c-card> | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user