@gentleduck/upload 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +297 -0
- package/dist/core/index.d.ts +2 -3
- package/dist/core/index.js +1 -1
- package/dist/core-BVgloTiP.js +10 -0
- package/dist/index-DCqyBVcx.d.ts +1441 -0
- package/dist/index-DsZHPmJm.d.ts +225 -0
- package/dist/index-KROXtLQh.d.ts +281 -0
- package/dist/index.d.ts +4 -5
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +2 -90
- package/dist/react/index.js +1 -1
- package/dist/react-euPrDv24.js +1 -0
- package/dist/strategies/index.d.ts +2 -56
- package/dist/strategies/index.js +1 -1
- package/dist/strategies-CUF0s68j.js +1 -0
- package/package.json +3 -2
- package/src/__tests__/emitter.test.ts +85 -0
- package/src/__tests__/fingerprint.test.ts +49 -0
- package/src/__tests__/guards.test.ts +40 -0
- package/src/__tests__/id.test.ts +67 -0
- package/src/__tests__/mime-sniff.test.ts +142 -0
- package/src/__tests__/persistence.test.ts +191 -0
- package/src/__tests__/sanitize-filename.test.ts +109 -0
- package/src/__tests__/sleep.test.ts +34 -0
- package/src/__tests__/static-analysis.test.ts +145 -0
- package/src/__tests__/store-libs.test.ts +192 -0
- package/src/__tests__/transport-fetch.test.ts +108 -0
- package/src/__tests__/transport-libs.test.ts +42 -0
- package/src/__tests__/transport.test.ts +182 -0
- package/src/core/__tests__/errors.test.ts +152 -0
- package/src/core/client.ts +3 -0
- package/src/core/contracts/contracts.types.ts +373 -0
- package/src/core/contracts/index.ts +3 -8
- package/src/core/contracts/transport/index.ts +3 -2
- package/src/core/contracts/transport/transport.fetch.ts +203 -0
- package/src/core/contracts/transport/transport.libs.ts +13 -39
- package/src/core/contracts/transport/transport.ts +96 -90
- package/src/core/contracts/transport/transport.types.ts +71 -35
- package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
- package/src/core/engine/engine.libs.ts +35 -0
- package/src/core/engine/engine.types.ts +490 -0
- package/src/core/engine/index.ts +3 -8
- package/src/core/engine/reducer.ts +93 -53
- package/src/core/engine/store/handlers/add-file.ts +43 -48
- package/src/core/engine/store/handlers/cancel.ts +13 -8
- package/src/core/engine/store/handlers/clean-up.ts +11 -8
- package/src/core/engine/store/handlers/create-intent.ts +82 -27
- package/src/core/engine/store/handlers/finalize.ts +30 -13
- package/src/core/engine/store/handlers/index.ts +7 -7
- package/src/core/engine/store/handlers/pause.ts +8 -6
- package/src/core/engine/store/handlers/run-upload.ts +54 -39
- package/src/core/engine/store/index.ts +27 -21
- package/src/core/engine/store/store.dispatch.ts +10 -8
- package/src/core/engine/store/store.libs.ts +197 -94
- package/src/core/engine/store/store.runtime.ts +52 -53
- package/src/core/engine/store/store.schedule.ts +27 -21
- package/src/core/engine/store/store.types.ts +177 -227
- package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
- package/src/core/engine/validation/file.ts +8 -4
- package/src/core/engine/validation/index.ts +1 -0
- package/src/core/engine/validation/intent.ts +49 -27
- package/src/core/engine/validation/list.ts +14 -15
- package/src/core/engine/validation/mime.ts +39 -0
- package/src/core/errors.ts +271 -0
- package/src/core/index.ts +28 -5
- package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
- package/src/core/persistence/__tests__/persistence.test.ts +85 -0
- package/src/core/persistence/adapters.indexeddb.ts +9 -3
- package/src/core/persistence/adapters.local.ts +9 -3
- package/src/core/persistence/adapters.memory.ts +35 -13
- package/src/core/persistence/index.ts +5 -5
- package/src/core/persistence/persistence.ts +73 -60
- package/src/core/persistence/persistence.types.ts +140 -60
- package/src/core/utils/__tests__/guards.test.ts +93 -0
- package/src/core/utils/__tests__/url-safety.test.ts +112 -0
- package/src/core/utils/async.ts +7 -2
- package/src/core/utils/constants.ts +3 -9
- package/src/core/utils/emitter/emitter.ts +126 -0
- package/src/core/utils/emitter/emitter.types.ts +33 -16
- package/src/core/utils/emitter/index.ts +2 -74
- package/src/core/utils/fingerprint.ts +16 -5
- package/src/core/utils/guards.ts +91 -1
- package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
- package/src/core/utils/hash/index.ts +126 -0
- package/src/core/utils/hash/sha256.ts +179 -0
- package/src/core/utils/id.ts +33 -1
- package/src/core/utils/index.ts +15 -6
- package/src/core/utils/mime-sniff.ts +119 -0
- package/src/core/utils/sanitize-filename.ts +85 -0
- package/src/core/utils/url-safety.ts +156 -0
- package/src/react/__tests__/use-dropzone.test.ts +64 -0
- package/src/react/index.ts +5 -2
- package/src/react/upload-provider.tsx +59 -95
- package/src/react/uploader.types.ts +80 -0
- package/src/react/use-dropzone.tsx +226 -0
- package/src/react/use-uploader.tsx +97 -93
- package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
- package/src/strategies/_shared/retry.ts +79 -0
- package/src/strategies/index.ts +9 -15
- package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
- package/src/strategies/multipart/index.ts +211 -116
- package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
- package/src/strategies/post/index.ts +113 -71
- package/src/strategies/put/__tests__/put.test.ts +132 -0
- package/src/strategies/put/index.ts +139 -0
- package/src/strategies/registry/index.ts +12 -9
- package/src/strategies/tus/__tests__/tus.test.ts +159 -0
- package/src/strategies/tus/index.ts +211 -0
- package/dist/async-DxRWu4le.js +0 -1
- package/dist/core-BNxznRif.js +0 -2
- package/dist/index-BXg9WIce.d.ts +0 -987
- package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
- package/src/core/client/config.types.ts +0 -77
- package/src/core/client/index.ts +0 -4
- package/src/core/client/plugins.types.ts +0 -46
- package/src/core/client/retry.types.ts +0 -6
- package/src/core/client/uploadClient.ts +0 -19
- package/src/core/contracts/backendApi.ts +0 -62
- package/src/core/contracts/cursor.types.ts +0 -23
- package/src/core/contracts/errors.types.ts +0 -67
- package/src/core/contracts/fingerprint.types.ts +0 -18
- package/src/core/contracts/intent.types.ts +0 -39
- package/src/core/contracts/result.types.ts +0 -11
- package/src/core/contracts/strategy.types.ts +0 -45
- package/src/core/engine/commands.types.ts +0 -17
- package/src/core/engine/event-map.types.ts +0 -45
- package/src/core/engine/internal-events.types.ts +0 -222
- package/src/core/engine/outcome.types.ts +0 -9
- package/src/core/engine/progress.types.ts +0 -15
|
@@ -1,56 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
//#region src/strategies/multipart/index.d.ts
|
|
4
|
-
type MultipartIntent = {
|
|
5
|
-
strategy: 'multipart';
|
|
6
|
-
fileId: string;
|
|
7
|
-
uploadId: string;
|
|
8
|
-
partSize: number;
|
|
9
|
-
partCount: number;
|
|
10
|
-
parts?: Array<{
|
|
11
|
-
partNumber: number;
|
|
12
|
-
url: string;
|
|
13
|
-
headers?: Record<string, string>;
|
|
14
|
-
}>;
|
|
15
|
-
};
|
|
16
|
-
type MultipartCursor = {
|
|
17
|
-
done: Array<{
|
|
18
|
-
partNumber: number;
|
|
19
|
-
etag: string;
|
|
20
|
-
size: number;
|
|
21
|
-
}>;
|
|
22
|
-
/**
|
|
23
|
-
* Marks that the multipart session was completed on the backend (parts assembled).
|
|
24
|
-
* This prevents re-sending completeMultipart on resume.
|
|
25
|
-
*/
|
|
26
|
-
completed?: true;
|
|
27
|
-
};
|
|
28
|
-
declare function multipartStrategy<M extends IntentMap & {
|
|
29
|
-
multipart: MultipartIntent;
|
|
30
|
-
}, C extends CursorMap<M> & {
|
|
31
|
-
multipart?: MultipartCursor;
|
|
32
|
-
}, P extends string = string, R extends UploadResultBase = UploadResultBase>(opts?: {
|
|
33
|
-
maxPartConcurrency?: number;
|
|
34
|
-
}): UploadStrategy<M, C, P, R, 'multipart'>;
|
|
35
|
-
//#endregion
|
|
36
|
-
//#region src/strategies/post/index.d.ts
|
|
37
|
-
type PostIntent = {
|
|
38
|
-
/** Strategy identifier - must be 'post' */strategy: 'post'; /** Backend file identifier */
|
|
39
|
-
fileId: string; /** Presigned POST URL (form action) */
|
|
40
|
-
url: string; /** Form fields to include before the file */
|
|
41
|
-
fields: Record<string, string>; /** Optional expiration timestamp for the presigned URL */
|
|
42
|
-
expiresAt?: string;
|
|
43
|
-
};
|
|
44
|
-
type PostCursor = {
|
|
45
|
-
/** Bytes uploaded (for progress display only) */uploadedBytes: number;
|
|
46
|
-
};
|
|
47
|
-
declare function PostStrategy<M extends IntentMap & {
|
|
48
|
-
post: PostIntent;
|
|
49
|
-
}, C extends CursorMap<M> & {
|
|
50
|
-
post?: PostCursor;
|
|
51
|
-
}, P extends string = string, R extends UploadResultBase = UploadResultBase>(): UploadStrategy<M, C, P, R, 'post'>;
|
|
52
|
-
//#endregion
|
|
53
|
-
//#region src/strategies/registry/index.d.ts
|
|
54
|
-
declare function createStrategyRegistry<M extends IntentMap, C extends CursorMap<M>, P extends string = string, R extends UploadResultBase = UploadResultBase>(): StrategyRegistry<M, C, P, R>;
|
|
55
|
-
//#endregion
|
|
56
|
-
export { MultipartCursor, MultipartIntent, PostCursor, PostIntent, PostStrategy, type StrategyCtx, type StrategyRegistry, type UploadStrategy, createStrategyRegistry, multipartStrategy };
|
|
1
|
+
import { a as PutStrategy, c as MultipartStrategy, i as PutIntent, l as multipartStrategy, n as TusStrategy, o as PostIntent, r as createStrategyRegistry, s as PostStrategy, t as TusIntent } from "../index-KROXtLQh.js";
|
|
2
|
+
export { MultipartStrategy, PostIntent, PostStrategy, PutIntent, PutStrategy, TusIntent, TusStrategy, createStrategyRegistry, multipartStrategy };
|
package/dist/strategies/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{a as e,i as t,n,r,t as i}from"../strategies-CUF0s68j.js";export{t as PostStrategy,r as PutStrategy,i as TusStrategy,n as createStrategyRegistry,e as multipartStrategy};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{d as e,m as t}from"./core-BVgloTiP.js";function n(e){let t=/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(e);if(!t||t[1]===void 0||t[2]===void 0)return null;let n=parseInt(t[1],16),r=parseInt(t[2],16);return!Number.isFinite(n)||!Number.isFinite(r)||n<0||n>65535||r<0||r>65535?null:`${n>>8&255}.${n&255}.${r>>8&255}.${r&255}`}function r(e){let t=e.startsWith(`[`)&&e.endsWith(`]`)?e.slice(1,-1):e;t.endsWith(`.`)&&(t=t.slice(0,-1));let i=/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(t);if(i){let e=Number(i[1]),t=Number(i[2]),n=Number(i[3]),r=Number(i[4]);return e>255||t>255||n>255||r>255?!1:e===127||e===10||e===192&&t===168||e===172&&t>=16&&t<=31||e===169&&t===254||e===0||e===100&&t>=64&&t<=127||e>=224&&e<=239||e>=240}if(t.includes(`:`)){let e=t.toLowerCase();if(e===`::1`||e===`0:0:0:0:0:0:0:1`||e===`::`||e===`0:0:0:0:0:0:0:0`||/^f[cd][0-9a-f]{0,2}:/.test(e)||/^fe[89ab][0-9a-f]?:/.test(e)||/^ff[0-9a-f]{0,2}:/.test(e))return!0;let i=null;if(e.startsWith(`::ffff:`)?i=e.slice(7):e.startsWith(`0:0:0:0:0:ffff:`)&&(i=e.slice(15)),i!==null){if(i.includes(`.`))return r(i);let e=n(i);return e?r(e):!1}if(e.startsWith(`::`)&&e.includes(`.`)){let t=e.slice(2);if(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.test(t))return r(t)}if(e.startsWith(`2002:`)){let t=/^2002:([0-9a-f]{1,4}):([0-9a-f]{1,4})(?::|$)/.exec(e);if(t){let e=n(`${t[1]}:${t[2]}`);if(e)return r(e)}}if(e.startsWith(`64:ff9b:`)||e.startsWith(`0064:ff9b:`)){let t=e.startsWith(`0064:`)?e.slice(10):e.slice(8);if(t.includes(`.`)){let e=/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(t);if(e?.[1]!==void 0)return r(e[1])}let i=t.split(`:`).filter(e=>e.length>0);if(i.length>=2){let e=n(`${i[i.length-2]}:${i[i.length-1]}`);if(e)return r(e)}}return!1}return!1}function i(e,t,n={}){if(typeof e!=`string`||e.length===0)throw Error(`${t} returned an invalid URL (empty or non-string)`);if(e.length>8192)throw Error(`${t} URL exceeds 8192-byte length cap`);if(e.includes(`..`))throw Error(`${t} URL contains forbidden ".." segment`);let i;try{i=new URL(e)}catch{throw Error(`${t} returned a malformed URL`)}if(i.protocol!==`http:`&&i.protocol!==`https:`)throw Error(`${t} URL has forbidden protocol "${i.protocol}"`);if(n.allowedHosts&&n.allowedHosts.length>0){let e=i.host.toLowerCase();if(!n.allowedHosts.map(e=>e.toLowerCase()).includes(e))throw Error(`${t} URL host is not in the configured allow-list`)}if(!n.allowPrivateHosts&&r(i.hostname))throw Error(`${t} URL points to a private/loopback host`)}function a(e){return e instanceof Error&&(e.name===`AbortError`||/abort/i.test(e.message))}function o(e){let t=e instanceof Error?e.message:String(e);return/network/i.test(t)||/timeout/i.test(t)||/5\d\d/.test(t)||/ECONNRESET/i.test(t)||/EHOSTUNREACH/i.test(t)}async function s(t,n={}){let r=Math.max(0,n.maxRetries??3),i=n.retryable??o,s=0;for(;;)try{return await t(s)}catch(t){if(n.signal?.aborted||a(t)||s>=r||!i(t))throw t;await e(2**s*500),s++}}let c=!1;function l(e,t={}){(!t.allowedHosts||t.allowedHosts.length===0)&&!c&&(c=!0,console.warn("[duck-upload] multipartStrategy: no `allowedHosts` configured. Signed part URLs will be host-unrestricted. Set MultipartStrategy.Config.allowedHosts to lock the upload host.")),i(e,`multipart.signPart`,t)}function u(e){let n=Math.max(1,e?.maxPartConcurrency??4),r=e?.allowedHosts??[],i=e?.allowPrivateHosts===!0;return{id:`multipart`,resumable:!0,async start(e){let a=e.intent,o=e.file.size,c=Math.max(1,a.partSize),u=Math.max(1,a.partCount??Math.ceil(o/c)),d=e.readCursor(),f=new Map;if(d?.done)for(let e of d.done)f.set(e.partNumber,{etag:e.etag,size:e.size});let p=d?.completed===!0,m=new Map,h=0;for(let e of f.values())h+=e.size;let g=()=>{let t=0;for(let e of m.values())t+=e;e.reportProgress({uploadedBytes:h+t,totalBytes:o})},_=[];for(let e=1;e<=u;e++){if(f.has(e))continue;let t=(e-1)*c,n=Math.min(o,t+c);_.push({partNumber:e,start:t,end:n})}if(_.length===0){p||await S(),g();return}let v=_.slice(),y=new Set,b=async n=>{let r=a.parts?.find(e=>e.partNumber===n);if(r)return r;if(!e.api.multipart?.signPart)throw new t(`validation_failed`,{message:`multipart.signPart is missing in UploadApi. Implement it to call your backend sign-part endpoint.`});let i=await e.api.multipart.signPart({fileId:a.fileId,uploadId:a.uploadId,partNumber:n,attempt:e.attempt},{localId:e.localId,purpose:e.purpose,file:e.file,intent:e.intent,signal:e.signal,fingerprint:e.fingerprint,attempt:e.attempt,createdAt:e.createdAt,steps:e.steps,meta:e.meta,lastError:e.lastError});return{partNumber:n,url:i.url,headers:i.headers}},x=n=>s(async()=>{try{let a=e.file.slice(n.start,n.end),o=a.size,s=await b(n.partNumber);l(s.url,{allowedHosts:r,allowPrivateHosts:i});let c=await e.transport.put({url:s.url,body:a,headers:s.headers??{},signal:e.signal,onProgress:e=>{m.set(n.partNumber,e),g()}});m.delete(n.partNumber);let u=c.etag;if(!u)throw new t(`upload_failed`,{message:`Missing ETag from upload part response. Ensure MinIO/S3 CORS exposes ETag (Access-Control-Expose-Headers: ETag).`});h+=o,f.set(n.partNumber,{etag:u,size:o});let d={done:Array.from(f.entries()).map(([e,t])=>({partNumber:e,etag:t.etag,size:t.size})).sort((e,t)=>e.partNumber-t.partNumber)};e.persistCursor(d),g()}catch(e){throw m.delete(n.partNumber),e}},{signal:e.signal});for(;v.length>0||y.size>0;){for(;v.length>0&&y.size<n;){let e=v.shift();if(!e)break;let t,n=x(e).finally(()=>{t&&y.delete(t)});t=n,y.add(n)}y.size>0&&await Promise.race(y)}await S(),g();async function S(){if(p)return;if(!e.api.multipart?.completeMultipart)throw new t(`validation_failed`,{message:`multipart.completeMultipart is missing in UploadApi. Implement it to call your backend complete endpoint.`});let n=Array.from(f.entries()).map(([e,t])=>({partNumber:e,etag:t.etag})).sort((e,t)=>e.partNumber-t.partNumber);if(n.length!==u)throw new t(`complete_failed`,{message:`Cannot complete multipart: expected ${u} parts, got ${n.length}`});await e.api.multipart.completeMultipart({fileId:a.fileId,uploadId:a.uploadId,parts:n},{localId:e.localId,purpose:e.purpose,file:e.file,intent:e.intent,signal:e.signal,fingerprint:e.fingerprint,attempt:e.attempt,createdAt:e.createdAt,steps:e.steps,meta:e.meta,lastError:e.lastError});let r={done:Array.from(f.entries()).map(([e,t])=>({partNumber:e,etag:t.etag,size:t.size})).sort((e,t)=>e.partNumber-t.partNumber),completed:!0};e.persistCursor(r)}}}}let d=!1;function f(e={}){let n=e.allowedHosts??[],r=e.allowPrivateHosts===!0;return{id:`post`,resumable:!1,async start(a){let o=a.intent;if(!o.url)throw new t(`validation_failed`,{message:`post.start: intent missing url`});n.length===0&&!d&&(d=!0,console.warn("[duck-upload] PostStrategy: no `allowedHosts` configured. Presigned POST URLs will be host-unrestricted. Set PostStrategy.Config.allowedHosts to lock the upload host.")),i(o.url,`post.intent`,{allowedHosts:n,allowPrivateHosts:r}),await s(()=>a.transport.postForm({url:o.url,file:a.file,fields:o.fields,filename:a.file.name,signal:a.signal,onProgress(e,t){a.reportProgress({uploadedBytes:e,totalBytes:t})}}),{signal:a.signal,...e.maxRetries===void 0?{}:{maxRetries:e.maxRetries}})}}}let p=!1;function m(e={}){let n=e.allowedHosts??[],r=e.allowPrivateHosts===!0;return{id:`put`,resumable:!1,async start(a){let o=a.intent;if(!o.url)throw new t(`validation_failed`,{message:`put.start: intent missing url`});n.length===0&&!p&&(p=!0,console.warn("[duck-upload] PutStrategy: no `allowedHosts` configured. Presigned PUT URLs will be host-unrestricted. Set PutStrategy.Config.allowedHosts to lock the upload host.")),i(o.url,`put.intent`,{allowedHosts:n,allowPrivateHosts:r});let c=a.file.size;await s(()=>a.transport.put({url:o.url,body:a.file,headers:o.headers??{},signal:a.signal,onProgress(e,t){a.reportProgress({uploadedBytes:e,totalBytes:t||c})}}),{signal:a.signal,...e.maxRetries===void 0?{}:{maxRetries:e.maxRetries}})}}}function h(){let e={};return{get(t){return e[t]},has(t){return Object.hasOwn(e,t)},set(t){e[t.id]=t}}}let g=!1;function _(e,t){let n=e?.[t]??e?.[t.toLowerCase()];if(n===void 0)return null;let r=Number(n);return Number.isInteger(r)&&r>=0?r:null}function v(e={}){let n=e.allowedHosts??[],r=e.allowPrivateHosts===!0,a=e.chunkSize,o=e.maxRetries===void 0?{}:{maxRetries:e.maxRetries};return{id:`tus`,resumable:!0,async start(e){let c=e.intent;if(!c.url)throw new t(`validation_failed`,{message:`tus.start: intent missing url`});n.length===0&&!g&&(g=!0,console.warn("[duck-upload] TusStrategy: no `allowedHosts` configured. tus upload URLs will be host-unrestricted. Set TusStrategy.Config.allowedHosts to lock the upload host.")),i(c.url,`tus.intent`,{allowedHosts:n,allowPrivateHosts:r});let l=c.url,u=e.file.size,d=Math.max(1,c.chunkSize??a??8388608),f={"Tus-Resumable":`1.0.0`,...c.headers??{}},p=e.readCursor()?.offset??0;if(e.transport.head){let t=e.transport.head;try{let n=_((await s(()=>t({url:l,headers:f,signal:e.signal}),{signal:e.signal,...o})).headers,`upload-offset`);n!==null&&(p=n)}catch(t){if(e.signal?.aborted||t instanceof Error&&t.name===`AbortError`)throw t}}for(p>u&&(p=u),e.reportProgress({uploadedBytes:p,totalBytes:u});p<u;){if(e.signal?.aborted)throw new DOMException(`The operation was aborted.`,`AbortError`);let n=p,r=Math.min(u,n+d),i=e.file.slice(n,r),a=_((await s(()=>e.transport.patch({url:l,body:i,headers:{...f,"Upload-Offset":String(n),"Content-Type":`application/offset+octet-stream`},signal:e.signal,onProgress:t=>{e.reportProgress({uploadedBytes:n+t,totalBytes:u})}}),{signal:e.signal,...o})).headers,`upload-offset`);if(p=a===null?r:a,p<=n)throw new t(`upload_failed`,{message:`tus: server did not advance Upload-Offset (stuck at ${p} of ${u}).`});e.persistCursor({offset:p}),e.reportProgress({uploadedBytes:p,totalBytes:u})}}}}export{u as a,f as i,h as n,m as r,v as t};
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@gentleduck/upload",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
8
8
|
"type": "module",
|
|
9
|
+
"sideEffects": false,
|
|
9
10
|
"exports": {
|
|
10
11
|
".": {
|
|
11
12
|
"types": "./dist/index.d.ts",
|
|
@@ -52,7 +53,7 @@
|
|
|
52
53
|
"tsdown": "catalog:",
|
|
53
54
|
"typescript": "catalog:",
|
|
54
55
|
"vite": "catalog:",
|
|
55
|
-
"vitest": "^
|
|
56
|
+
"vitest": "^5.0.0"
|
|
56
57
|
},
|
|
57
58
|
"engines": {
|
|
58
59
|
"node": ">=22.0.0"
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest'
|
|
2
|
+
import { createTypedEmitter } from '../core/utils/emitter'
|
|
3
|
+
|
|
4
|
+
type Events = { foo: { x: number }; bar: { y: string } }
|
|
5
|
+
|
|
6
|
+
describe('createTypedEmitter', () => {
|
|
7
|
+
test('emits to subscribers', () => {
|
|
8
|
+
const e = createTypedEmitter<Events>()
|
|
9
|
+
const fn = vi.fn()
|
|
10
|
+
e.on('foo', fn)
|
|
11
|
+
e.emit('foo', { x: 1 })
|
|
12
|
+
e.emit('foo', { x: 2 })
|
|
13
|
+
expect(fn).toHaveBeenCalledTimes(2)
|
|
14
|
+
expect(fn).toHaveBeenNthCalledWith(1, { x: 1 })
|
|
15
|
+
expect(fn).toHaveBeenNthCalledWith(2, { x: 2 })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('does not invoke unrelated handlers', () => {
|
|
19
|
+
const e = createTypedEmitter<Events>()
|
|
20
|
+
const fooFn = vi.fn()
|
|
21
|
+
const barFn = vi.fn()
|
|
22
|
+
e.on('foo', fooFn)
|
|
23
|
+
e.on('bar', barFn)
|
|
24
|
+
e.emit('bar', { y: 'z' })
|
|
25
|
+
expect(fooFn).not.toHaveBeenCalled()
|
|
26
|
+
expect(barFn).toHaveBeenCalledOnce()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('on returns unsubscriber', () => {
|
|
30
|
+
const e = createTypedEmitter<Events>()
|
|
31
|
+
const fn = vi.fn()
|
|
32
|
+
const off = e.on('foo', fn)
|
|
33
|
+
off()
|
|
34
|
+
e.emit('foo', { x: 1 })
|
|
35
|
+
expect(fn).not.toHaveBeenCalled()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('off detaches listener', () => {
|
|
39
|
+
const e = createTypedEmitter<Events>()
|
|
40
|
+
const fn = vi.fn()
|
|
41
|
+
e.on('foo', fn)
|
|
42
|
+
e.off('foo', fn)
|
|
43
|
+
e.emit('foo', { x: 1 })
|
|
44
|
+
expect(fn).not.toHaveBeenCalled()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('isolates throwing listeners', () => {
|
|
48
|
+
const e = createTypedEmitter<Events>()
|
|
49
|
+
const ok = vi.fn()
|
|
50
|
+
e.on('foo', () => {
|
|
51
|
+
throw new Error('boom')
|
|
52
|
+
})
|
|
53
|
+
e.on('foo', ok)
|
|
54
|
+
expect(() => e.emit('foo', { x: 1 })).not.toThrow()
|
|
55
|
+
expect(ok).toHaveBeenCalledOnce()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('subsequent on() adds the listener for future emits', () => {
|
|
59
|
+
const e = createTypedEmitter<Events>()
|
|
60
|
+
const a: number[] = []
|
|
61
|
+
const b: number[] = []
|
|
62
|
+
e.on('foo', (p: any) => a.push(p.x))
|
|
63
|
+
e.on('foo', (p: any) => b.push(p.x))
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < 5; i++) e.emit('foo', { x: i })
|
|
66
|
+
expect(a).toEqual([0, 1, 2, 3, 4])
|
|
67
|
+
expect(b).toEqual([0, 1, 2, 3, 4])
|
|
68
|
+
|
|
69
|
+
const c: number[] = []
|
|
70
|
+
e.on('foo', (p: any) => c.push(p.x))
|
|
71
|
+
e.emit('foo', { x: 99 })
|
|
72
|
+
expect(c).toEqual([99])
|
|
73
|
+
expect(a).toEqual([0, 1, 2, 3, 4, 99])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('off() with a non-subscribed callback is a no-op', () => {
|
|
77
|
+
const e = createTypedEmitter<Events>()
|
|
78
|
+
const tracker: number[] = []
|
|
79
|
+
e.on('foo', (p: any) => tracker.push(p.x))
|
|
80
|
+
const unrelated = (p: { x: number }) => tracker.push(-p.x)
|
|
81
|
+
expect(() => e.off('foo', unrelated)).not.toThrow()
|
|
82
|
+
e.emit('foo', { x: 7 })
|
|
83
|
+
expect(tracker).toEqual([7])
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import { computeFingerprint, fingerprintMatches } from '../core/utils/fingerprint'
|
|
3
|
+
|
|
4
|
+
function fileOf(name: string, content: string, type = 'text/plain', lastModified = 0): File {
|
|
5
|
+
return new File([content], name, { type, lastModified })
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe('computeFingerprint', () => {
|
|
9
|
+
test('captures name, size, type, lastModified', () => {
|
|
10
|
+
const f = fileOf('a.txt', 'hello', 'text/plain', 1234)
|
|
11
|
+
expect(computeFingerprint(f)).toEqual({
|
|
12
|
+
name: 'a.txt',
|
|
13
|
+
size: 5,
|
|
14
|
+
type: f.type,
|
|
15
|
+
lastModified: 1234,
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('fingerprintMatches', () => {
|
|
21
|
+
test('same identity matches', () => {
|
|
22
|
+
const f = fileOf('a.txt', 'hello', 'text/plain', 1234)
|
|
23
|
+
expect(fingerprintMatches(computeFingerprint(f), computeFingerprint(f))).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('different name does not match', () => {
|
|
27
|
+
const a = computeFingerprint(fileOf('a.txt', 'hello'))
|
|
28
|
+
const b = computeFingerprint(fileOf('b.txt', 'hello'))
|
|
29
|
+
expect(fingerprintMatches(a, b)).toBe(false)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('different size does not match', () => {
|
|
33
|
+
const a = computeFingerprint(fileOf('a.txt', 'hello'))
|
|
34
|
+
const b = computeFingerprint(fileOf('a.txt', 'hello!'))
|
|
35
|
+
expect(fingerprintMatches(a, b)).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('different lastModified does not match', () => {
|
|
39
|
+
const a = computeFingerprint(fileOf('a.txt', 'hello', 'text/plain', 1))
|
|
40
|
+
const b = computeFingerprint(fileOf('a.txt', 'hello', 'text/plain', 2))
|
|
41
|
+
expect(fingerprintMatches(a, b)).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('type difference is intentionally ignored', () => {
|
|
45
|
+
const a = computeFingerprint(fileOf('a', 'hello', 'text/plain', 1))
|
|
46
|
+
const b = computeFingerprint(fileOf('a', 'hello', 'application/octet-stream', 1))
|
|
47
|
+
expect(fingerprintMatches(a, b)).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import { isRecord } from '../core/utils/guards'
|
|
3
|
+
|
|
4
|
+
describe('isRecord', () => {
|
|
5
|
+
test('plain object', () => {
|
|
6
|
+
expect(isRecord({})).toBe(true)
|
|
7
|
+
expect(isRecord({ a: 1 })).toBe(true)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('null', () => {
|
|
11
|
+
expect(isRecord(null)).toBe(false)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('undefined', () => {
|
|
15
|
+
expect(isRecord(undefined)).toBe(false)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('primitives', () => {
|
|
19
|
+
expect(isRecord(0)).toBe(false)
|
|
20
|
+
expect(isRecord('a')).toBe(false)
|
|
21
|
+
expect(isRecord(true)).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('arrays are rejected', () => {
|
|
25
|
+
expect(isRecord([])).toBe(false)
|
|
26
|
+
expect(isRecord([1, 2, 3])).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('exotic objects are rejected', () => {
|
|
30
|
+
expect(isRecord(new Date())).toBe(false)
|
|
31
|
+
expect(isRecord(new Map())).toBe(false)
|
|
32
|
+
expect(isRecord(new Set())).toBe(false)
|
|
33
|
+
class Foo {}
|
|
34
|
+
expect(isRecord(new Foo())).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('null-prototype objects are accepted', () => {
|
|
38
|
+
expect(isRecord(Object.create(null))).toBe(true)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'vitest'
|
|
2
|
+
import { generateId } from '../core/utils/id'
|
|
3
|
+
|
|
4
|
+
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
5
|
+
|
|
6
|
+
describe('generateId', () => {
|
|
7
|
+
test('starts with the `upload_` prefix', () => {
|
|
8
|
+
expect(generateId()).toMatch(/^upload_/)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('embeds a v4 UUID after the timestamp', () => {
|
|
12
|
+
const id = generateId()
|
|
13
|
+
const match = id.match(/^upload_(\d+)_(.+)$/)
|
|
14
|
+
expect(match).not.toBeNull()
|
|
15
|
+
expect(match![2]).toMatch(UUID_V4)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('produces 1000 unique IDs (collision-resistance smoke)', () => {
|
|
19
|
+
const set = new Set<string>()
|
|
20
|
+
for (let i = 0; i < 1000; i++) set.add(generateId())
|
|
21
|
+
expect(set.size).toBe(1000)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('produces unique values across many calls', () => {
|
|
25
|
+
const set = new Set<string>()
|
|
26
|
+
for (let i = 0; i < 10_000; i++) set.add(generateId())
|
|
27
|
+
expect(set.size).toBe(10_000)
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('generateId fallback path (no crypto.randomUUID)', () => {
|
|
32
|
+
const originalCrypto = (globalThis as { crypto?: Crypto }).crypto
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
// Restore the original crypto reference for subsequent tests.
|
|
36
|
+
Object.defineProperty(globalThis, 'crypto', {
|
|
37
|
+
value: originalCrypto,
|
|
38
|
+
configurable: true,
|
|
39
|
+
writable: true,
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('falls back to getRandomValues and still produces a v4-shaped UUID', () => {
|
|
44
|
+
const fakeCrypto = {
|
|
45
|
+
// Simulate a runtime that lacks randomUUID but has getRandomValues.
|
|
46
|
+
getRandomValues: (arr: any) => originalCrypto!.getRandomValues(arr),
|
|
47
|
+
} as unknown as Crypto
|
|
48
|
+
Object.defineProperty(globalThis, 'crypto', {
|
|
49
|
+
value: fakeCrypto,
|
|
50
|
+
configurable: true,
|
|
51
|
+
writable: true,
|
|
52
|
+
})
|
|
53
|
+
const id = generateId()
|
|
54
|
+
const match = id.match(/^upload_\d+_(.+)$/)
|
|
55
|
+
expect(match).not.toBeNull()
|
|
56
|
+
expect(match![1]).toMatch(UUID_V4)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('throws when neither randomUUID nor getRandomValues are available', () => {
|
|
60
|
+
Object.defineProperty(globalThis, 'crypto', {
|
|
61
|
+
value: {} as Crypto,
|
|
62
|
+
configurable: true,
|
|
63
|
+
writable: true,
|
|
64
|
+
})
|
|
65
|
+
expect(() => generateId()).toThrow(/CSPRNG/)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
import { validateMimeSignature } from '../core/engine/validation/mime'
|
|
3
|
+
import { __mimeWarnings, mimeMatches, sniffMime } from '../core/utils/mime-sniff'
|
|
4
|
+
|
|
5
|
+
function bytes(...arr: number[]): Uint8Array<ArrayBuffer> {
|
|
6
|
+
return new Uint8Array(arr)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('sniffMime', () => {
|
|
10
|
+
test('detects JPEG (FF D8 FF)', () => {
|
|
11
|
+
expect(sniffMime(bytes(0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10))).toBe('image/jpeg')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('detects PNG (89 50 4E 47 0D 0A 1A 0A)', () => {
|
|
15
|
+
expect(sniffMime(bytes(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))).toBe('image/png')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('detects GIF (47 49 46 38)', () => {
|
|
19
|
+
expect(sniffMime(bytes(0x47, 0x49, 0x46, 0x38, 0x39, 0x61))).toBe('image/gif')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('detects PDF (%PDF)', () => {
|
|
23
|
+
expect(sniffMime(bytes(0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34))).toBe('application/pdf')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('detects ZIP family (PK..)', () => {
|
|
27
|
+
expect(sniffMime(bytes(0x50, 0x4b, 0x03, 0x04, 0x14, 0x00))).toBe('application/zip')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('detects MP4 ftyp box', () => {
|
|
31
|
+
expect(sniffMime(bytes(0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d))).toBe('video/mp4')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('detects WebP (RIFF....WEBP)', () => {
|
|
35
|
+
expect(sniffMime(bytes(0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50))).toBe('image/webp')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('detects WebM/Matroska', () => {
|
|
39
|
+
expect(sniffMime(bytes(0x1a, 0x45, 0xdf, 0xa3, 0x9f, 0x42))).toBe('video/webm')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('detects MP3 ID3 tag', () => {
|
|
43
|
+
expect(sniffMime(bytes(0x49, 0x44, 0x33, 0x04, 0x00, 0x00))).toBe('audio/mpeg')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('detects MP3 raw frame sync (FF FB)', () => {
|
|
47
|
+
expect(sniffMime(bytes(0xff, 0xfb, 0x90, 0x00))).toBe('audio/mpeg')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('returns null on unknown bytes', () => {
|
|
51
|
+
expect(sniffMime(bytes(0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b))).toBeNull()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('returns null on too-short buffers', () => {
|
|
55
|
+
expect(sniffMime(bytes(0xff))).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('mimeMatches', () => {
|
|
60
|
+
test('null sniff is always a match (unknown is not deny)', () => {
|
|
61
|
+
expect(mimeMatches('image/jpeg', null)).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('exact match', () => {
|
|
65
|
+
expect(mimeMatches('image/jpeg', 'image/jpeg')).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('empty / octet-stream claim is always a match (no claim made)', () => {
|
|
69
|
+
expect(mimeMatches('', 'image/jpeg')).toBe(true)
|
|
70
|
+
expect(mimeMatches('application/octet-stream', 'application/pdf')).toBe(true)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('JPEG common aliases', () => {
|
|
74
|
+
expect(mimeMatches('image/jpg', 'image/jpeg')).toBe(true)
|
|
75
|
+
expect(mimeMatches('image/pjpeg', 'image/jpeg')).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('Office DOCX is consistent with ZIP signature', () => {
|
|
79
|
+
expect(
|
|
80
|
+
mimeMatches('application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/zip'),
|
|
81
|
+
).toBe(true)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('JPEG bytes with claimed PDF type mismatch', () => {
|
|
85
|
+
expect(mimeMatches('application/pdf', 'image/jpeg')).toBe(false)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('validateMimeSignature', () => {
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
__mimeWarnings.clear()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('JPEG bytes with claimed PDF type → rejected when strict', async () => {
|
|
95
|
+
const file = new File([bytes(0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10)], 'evil.pdf', { type: 'application/pdf' })
|
|
96
|
+
const reason = await validateMimeSignature(file, true)
|
|
97
|
+
expect(reason).toEqual({ code: 'mime_mismatch', claimed: 'application/pdf', sniffed: 'image/jpeg' })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('JPEG bytes with claimed PDF type → null + console.warn when not strict', async () => {
|
|
101
|
+
const file = new File([bytes(0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10)], 'evil.pdf', { type: 'application/pdf' })
|
|
102
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
103
|
+
const reason = await validateMimeSignature(file, false)
|
|
104
|
+
expect(reason).toBeNull()
|
|
105
|
+
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
106
|
+
expect(String(warnSpy.mock.calls[0]?.[0] ?? '')).toMatch(/MIME mismatch/)
|
|
107
|
+
// Filename must not appear in the warning: it is untrusted input.
|
|
108
|
+
expect(String(warnSpy.mock.calls[0]?.[0] ?? '')).not.toMatch(/evil\.pdf/)
|
|
109
|
+
warnSpy.mockRestore()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('repeated identical mismatches only warn once', async () => {
|
|
113
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
114
|
+
for (let i = 0; i < 3; i++) {
|
|
115
|
+
const file = new File([bytes(0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10)], `f-${i}.pdf`, { type: 'application/pdf' })
|
|
116
|
+
await validateMimeSignature(file, false)
|
|
117
|
+
}
|
|
118
|
+
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
119
|
+
warnSpy.mockRestore()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('matching JPEG+JPEG → accepted', async () => {
|
|
123
|
+
const file = new File([bytes(0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10)], 'ok.jpg', { type: 'image/jpeg' })
|
|
124
|
+
expect(await validateMimeSignature(file, true)).toBeNull()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('unknown bytes → accepted (sniff returns null, no mismatch)', async () => {
|
|
128
|
+
const file = new File(
|
|
129
|
+
[bytes(0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b)],
|
|
130
|
+
'mystery.dat',
|
|
131
|
+
{
|
|
132
|
+
type: 'application/pdf',
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
expect(await validateMimeSignature(file, true)).toBeNull()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('empty file → null (defers to other validators)', async () => {
|
|
139
|
+
const file = new File([], 'empty.bin')
|
|
140
|
+
expect(await validateMimeSignature(file, true)).toBeNull()
|
|
141
|
+
})
|
|
142
|
+
})
|