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"> | <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…
	
		Reference in New Issue
	
	Block a user