@gentleduck/upload 0.2.6 → 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.
Files changed (136) hide show
  1. package/README.md +170 -193
  2. package/dist/core/index.d.ts +2 -3
  3. package/dist/core/index.js +1 -1
  4. package/dist/core-BVgloTiP.js +10 -0
  5. package/dist/index-DCqyBVcx.d.ts +1441 -0
  6. package/dist/index-DsZHPmJm.d.ts +225 -0
  7. package/dist/index-KROXtLQh.d.ts +281 -0
  8. package/dist/index.d.ts +4 -5
  9. package/dist/index.js +1 -1
  10. package/dist/react/index.d.ts +2 -51
  11. package/dist/react/index.js +1 -1
  12. package/dist/react-euPrDv24.js +1 -0
  13. package/dist/strategies/index.d.ts +2 -56
  14. package/dist/strategies/index.js +1 -1
  15. package/dist/strategies-CUF0s68j.js +1 -0
  16. package/package.json +3 -2
  17. package/src/{__test__ → __tests__}/emitter.test.ts +4 -4
  18. package/src/{__test__ → __tests__}/fingerprint.test.ts +1 -1
  19. package/src/{__test__ → __tests__}/guards.test.ts +15 -5
  20. package/src/__tests__/id.test.ts +67 -0
  21. package/src/__tests__/mime-sniff.test.ts +142 -0
  22. package/src/{__test__ → __tests__}/persistence.test.ts +43 -8
  23. package/src/__tests__/sanitize-filename.test.ts +109 -0
  24. package/src/__tests__/sleep.test.ts +34 -0
  25. package/src/__tests__/static-analysis.test.ts +145 -0
  26. package/src/{__test__ → __tests__}/store-libs.test.ts +57 -3
  27. package/src/__tests__/transport-fetch.test.ts +108 -0
  28. package/src/{__test__ → __tests__}/transport.test.ts +5 -5
  29. package/src/core/__tests__/errors.test.ts +152 -0
  30. package/src/core/client.ts +3 -0
  31. package/src/core/contracts/contracts.types.ts +335 -141
  32. package/src/core/contracts/index.ts +3 -8
  33. package/src/core/contracts/transport/index.ts +3 -2
  34. package/src/core/contracts/transport/transport.fetch.ts +203 -0
  35. package/src/core/contracts/transport/transport.libs.ts +7 -8
  36. package/src/core/contracts/transport/transport.ts +91 -83
  37. package/src/core/contracts/transport/transport.types.ts +71 -35
  38. package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
  39. package/src/core/engine/engine.libs.ts +35 -0
  40. package/src/core/engine/engine.types.ts +367 -241
  41. package/src/core/engine/index.ts +3 -8
  42. package/src/core/engine/reducer.ts +89 -43
  43. package/src/core/engine/store/handlers/add-file.ts +39 -29
  44. package/src/core/engine/store/handlers/cancel.ts +13 -8
  45. package/src/core/engine/store/handlers/clean-up.ts +11 -8
  46. package/src/core/engine/store/handlers/create-intent.ts +82 -27
  47. package/src/core/engine/store/handlers/finalize.ts +30 -13
  48. package/src/core/engine/store/handlers/index.ts +7 -7
  49. package/src/core/engine/store/handlers/pause.ts +8 -6
  50. package/src/core/engine/store/handlers/run-upload.ts +49 -34
  51. package/src/core/engine/store/index.ts +27 -21
  52. package/src/core/engine/store/store.dispatch.ts +10 -8
  53. package/src/core/engine/store/store.libs.ts +184 -41
  54. package/src/core/engine/store/store.runtime.ts +45 -36
  55. package/src/core/engine/store/store.schedule.ts +27 -21
  56. package/src/core/engine/store/store.types.ts +177 -227
  57. package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
  58. package/src/core/engine/validation/file.ts +7 -3
  59. package/src/core/engine/validation/index.ts +1 -0
  60. package/src/core/engine/validation/intent.ts +49 -27
  61. package/src/core/engine/validation/list.ts +14 -15
  62. package/src/core/engine/validation/mime.ts +39 -0
  63. package/src/core/errors.ts +271 -0
  64. package/src/core/index.ts +28 -5
  65. package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
  66. package/src/core/persistence/__tests__/persistence.test.ts +85 -0
  67. package/src/core/persistence/adapters.indexeddb.ts +9 -3
  68. package/src/core/persistence/adapters.local.ts +9 -3
  69. package/src/core/persistence/adapters.memory.ts +35 -13
  70. package/src/core/persistence/index.ts +5 -5
  71. package/src/core/persistence/persistence.ts +73 -60
  72. package/src/core/persistence/persistence.types.ts +140 -60
  73. package/src/core/utils/__tests__/guards.test.ts +93 -0
  74. package/src/core/utils/__tests__/url-safety.test.ts +112 -0
  75. package/src/core/utils/async.ts +7 -2
  76. package/src/core/utils/constants.ts +3 -9
  77. package/src/core/utils/emitter/emitter.ts +7 -24
  78. package/src/core/utils/emitter/emitter.types.ts +33 -16
  79. package/src/core/utils/emitter/index.ts +2 -74
  80. package/src/core/utils/fingerprint.ts +16 -5
  81. package/src/core/utils/guards.ts +91 -1
  82. package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
  83. package/src/core/utils/hash/index.ts +126 -0
  84. package/src/core/utils/hash/sha256.ts +179 -0
  85. package/src/core/utils/id.ts +33 -1
  86. package/src/core/utils/index.ts +15 -6
  87. package/src/core/utils/mime-sniff.ts +119 -0
  88. package/src/core/utils/sanitize-filename.ts +85 -0
  89. package/src/core/utils/url-safety.ts +156 -0
  90. package/src/react/__tests__/use-dropzone.test.ts +64 -0
  91. package/src/react/index.ts +5 -2
  92. package/src/react/upload-provider.tsx +68 -53
  93. package/src/react/uploader.types.ts +58 -61
  94. package/src/react/use-dropzone.tsx +226 -0
  95. package/src/react/use-uploader.tsx +98 -91
  96. package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
  97. package/src/strategies/_shared/retry.ts +79 -0
  98. package/src/strategies/index.ts +9 -15
  99. package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
  100. package/src/strategies/multipart/index.ts +206 -108
  101. package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
  102. package/src/strategies/post/index.ts +115 -33
  103. package/src/strategies/put/__tests__/put.test.ts +132 -0
  104. package/src/strategies/put/index.ts +139 -0
  105. package/src/strategies/registry/index.ts +12 -9
  106. package/src/strategies/tus/__tests__/tus.test.ts +159 -0
  107. package/src/strategies/tus/index.ts +211 -0
  108. package/dist/async-DxRWu4le.js +0 -1
  109. package/dist/core-CMkrP9DX.js +0 -2
  110. package/dist/index-C38Jmizu.d.ts +0 -1005
  111. package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
  112. package/src/__test__/effect-pool.test.ts +0 -5
  113. package/src/__test__/id.test.ts +0 -14
  114. package/src/__test__/store-integration.test.ts +0 -5
  115. package/src/core/client/client.ts +0 -50
  116. package/src/core/client/client.types.ts +0 -184
  117. package/src/core/client/config.types.ts +0 -77
  118. package/src/core/client/index.ts +0 -4
  119. package/src/core/client/plugins.types.ts +0 -46
  120. package/src/core/client/retry.types.ts +0 -6
  121. package/src/core/client/uploadClient.ts +0 -19
  122. package/src/core/contracts/backendApi.ts +0 -62
  123. package/src/core/contracts/cursor.types.ts +0 -23
  124. package/src/core/contracts/errors.types.ts +0 -67
  125. package/src/core/contracts/fingerprint.types.ts +0 -18
  126. package/src/core/contracts/intent.types.ts +0 -39
  127. package/src/core/contracts/result.types.ts +0 -11
  128. package/src/core/contracts/strategy.types.ts +0 -45
  129. package/src/core/engine/commands.types.ts +0 -17
  130. package/src/core/engine/event-map.types.ts +0 -45
  131. package/src/core/engine/internal-events.types.ts +0 -202
  132. package/src/core/engine/outcome.types.ts +0 -9
  133. package/src/core/engine/progress.types.ts +0 -15
  134. package/src/core/engine/store/handlers.ts +0 -819
  135. package/src/core/engine/validation.ts +0 -216
  136. /package/src/{__test__ → __tests__}/transport-libs.test.ts +0 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Static-analysis assertions that enforce security invariants by
3
+ * grepping the source tree at test time.
4
+ *
5
+ * These guard the class of bug that survives a refactor + the existing
6
+ * happy-path tests: someone replaces `crypto.randomUUID` with
7
+ * `Math.random()`, or removes the SSRF guard from a strategy, and the
8
+ * existing tests still pass because they don't cover the regression
9
+ * lane. If one of these tests fails, fix the source - do not allowlist
10
+ * without understanding which threat the rule was protecting against.
11
+ */
12
+
13
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
14
+ import { dirname, join } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+ import { describe, expect, it } from 'vitest'
17
+
18
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
19
+
20
+ interface FileScan {
21
+ path: string
22
+ contents: string
23
+ }
24
+
25
+ function walkTs(dir: string, out: FileScan[] = []): FileScan[] {
26
+ for (const entry of readdirSync(dir)) {
27
+ if (entry === 'node_modules' || entry === 'dist' || entry.startsWith('__test')) continue
28
+ const full = join(dir, entry)
29
+ const stat = statSync(full)
30
+ if (stat.isDirectory()) {
31
+ walkTs(full, out)
32
+ } else if (entry.endsWith('.ts') || entry.endsWith('.tsx')) {
33
+ out.push({ path: full.slice(ROOT.length + 1), contents: readFileSync(full, 'utf8') })
34
+ }
35
+ }
36
+ return out
37
+ }
38
+
39
+ const ALL_FILES = walkTs(ROOT)
40
+
41
+ const SECURITY_PATHS = ['core/utils/', 'core/engine/', 'core/persistence/', 'core/contracts/', 'strategies/']
42
+
43
+ function isSecurityPath(path: string): boolean {
44
+ return SECURITY_PATHS.some((p) => path.includes(p))
45
+ }
46
+
47
+ function linesContaining(file: FileScan, pattern: RegExp): string[] {
48
+ const hits: string[] = []
49
+ for (const line of file.contents.split('\n')) {
50
+ const trimmed = line.trim()
51
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue
52
+ if (pattern.test(line)) hits.push(line)
53
+ }
54
+ return hits
55
+ }
56
+
57
+ describe('No Math.random in security paths', () => {
58
+ it('Math.random must not appear in core/utils, core/engine, core/persistence, core/contracts, strategies', () => {
59
+ const offenders = ALL_FILES.filter((f) => isSecurityPath(f.path)).flatMap((f) =>
60
+ linesContaining(f, /\bMath\.random\b/).map((l) => `${f.path}: ${l.trim()}`),
61
+ )
62
+ expect(offenders).toEqual([])
63
+ })
64
+ })
65
+
66
+ describe('Upload IDs come from a CSPRNG, never Math.random', () => {
67
+ it('id.ts uses randomUUID + getRandomValues; no Math.random in code', () => {
68
+ const id = ALL_FILES.find((f) => f.path === 'core/utils/id.ts')
69
+ expect(id).toBeDefined()
70
+ if (!id) return
71
+ expect(id.contents).toMatch(/\brandomUUID\b/)
72
+ expect(id.contents).toMatch(/\bgetRandomValues\b/)
73
+ // Use linesContaining so the docstring mention ("Math.random is deliberately
74
+ // NOT used") doesn't flag - we only inspect code lines.
75
+ expect(linesContaining(id, /Math\.random/)).toEqual([])
76
+ })
77
+ })
78
+
79
+ describe('Every strategy with a backend-supplied URL routes it through validateUploadUrl', () => {
80
+ it('multipart strategy imports validateUploadUrl', () => {
81
+ const f = ALL_FILES.find((f) => f.path === 'strategies/multipart/index.ts')
82
+ expect(f).toBeDefined()
83
+ if (!f) return
84
+ expect(f.contents).toMatch(/validateUploadUrl/)
85
+ })
86
+ it('post strategy imports validateUploadUrl', () => {
87
+ const f = ALL_FILES.find((f) => f.path === 'strategies/post/index.ts')
88
+ expect(f).toBeDefined()
89
+ if (!f) return
90
+ expect(f.contents).toMatch(/validateUploadUrl/)
91
+ })
92
+ })
93
+
94
+ describe('Persistence deserializer guards against NaN / Infinity / negative byte counts', () => {
95
+ it('persistence.ts uses Number.isFinite on numeric fields', () => {
96
+ const f = ALL_FILES.find((f) => f.path === 'core/persistence/persistence.ts')
97
+ expect(f).toBeDefined()
98
+ if (!f) return
99
+ expect(f.contents).toMatch(/Number\.isFinite|isFiniteNumber/)
100
+ })
101
+ })
102
+
103
+ describe('Persistence adapters strip dangerous keys before deserialization', () => {
104
+ it('LocalStorageAdapter applies stripDangerousKeys to JSON.parse output', () => {
105
+ const f = ALL_FILES.find((f) => f.path === 'core/persistence/adapters.local.ts')
106
+ expect(f).toBeDefined()
107
+ if (!f) return
108
+ expect(f.contents).toMatch(/stripDangerousKeys\(JSON\.parse/)
109
+ })
110
+ it('IndexedDB adapter applies stripDangerousKeys to read result', () => {
111
+ const f = ALL_FILES.find((f) => f.path === 'core/persistence/adapters.indexeddb.ts')
112
+ expect(f).toBeDefined()
113
+ if (!f) return
114
+ expect(f.contents).toMatch(/stripDangerousKeys/)
115
+ })
116
+ })
117
+
118
+ describe('UploadEngineError uses static messages (SEC-003)', () => {
119
+ it('errors.ts does NOT interpolate tainted values into Error.message', () => {
120
+ const f = ALL_FILES.find((f) => f.path === 'core/errors.ts')
121
+ expect(f).toBeDefined()
122
+ if (!f) return
123
+ // The constructor must lookup STATIC_MESSAGES[code] - no template-string interpolation
124
+ // of caller-supplied context values into the super() call.
125
+ expect(f.contents).toMatch(/STATIC_MESSAGES\[code\]/)
126
+ })
127
+ })
128
+
129
+ describe('Filename sanitization is wired into addFiles', () => {
130
+ it('create-intent.ts calls sanitizeFilename', () => {
131
+ const f = ALL_FILES.find((f) => f.path === 'core/engine/store/handlers/create-intent.ts')
132
+ expect(f).toBeDefined()
133
+ if (!f) return
134
+ expect(f.contents).toMatch(/sanitizeFilename/)
135
+ })
136
+ })
137
+
138
+ describe('No console.log in security paths (use console.warn for visibility)', () => {
139
+ it('security paths never call console.log', () => {
140
+ const offenders = ALL_FILES.filter((f) => isSecurityPath(f.path)).flatMap((f) =>
141
+ linesContaining(f, /\bconsole\.log\b/).map((l) => `${f.path}: ${l.trim()}`),
142
+ )
143
+ expect(offenders).toEqual([])
144
+ })
145
+ })
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from 'vitest'
1
+ import { describe, expect, test, vi } from 'vitest'
2
2
  import { resolveUploadConfig } from '../core/client'
3
3
  import {
4
4
  calculateFileChecksum,
@@ -121,11 +121,18 @@ describe('isMultipartIntent', () => {
121
121
  })
122
122
 
123
123
  describe('calculateFileChecksum', () => {
124
+ /** Reference digest via native Web Crypto for cross-checking the incremental path. */
125
+ async function subtleHex(bytes: Uint8Array): Promise<string> {
126
+ const digest = await crypto.subtle.digest('SHA-256', bytes as unknown as BufferSource)
127
+ return Array.from(new Uint8Array(digest))
128
+ .map((b) => b.toString(16).padStart(2, '0'))
129
+ .join('')
130
+ }
131
+
124
132
  test('produces a hex digest', async () => {
125
133
  const file = new File([new Uint8Array([0, 1, 2, 3])], 'a.bin')
126
134
  const h = await calculateFileChecksum(file)
127
- expect(h).toMatch(/^[0-9a-f]+$/)
128
- expect(h.length).toBeGreaterThan(0)
135
+ expect(h).toMatch(/^[0-9a-f]{64}$/)
129
136
  })
130
137
 
131
138
  test('different files produce different hashes', async () => {
@@ -135,4 +142,51 @@ describe('calculateFileChecksum', () => {
135
142
  const hb = await calculateFileChecksum(b)
136
143
  expect(ha).not.toBe(hb)
137
144
  })
145
+
146
+ test('file at the boundary uses the inline Web Crypto path (arrayBuffer once)', async () => {
147
+ const bytes = new Uint8Array(64)
148
+ for (let i = 0; i < bytes.length; i++) bytes[i] = i
149
+ const file = new File([bytes], 'cap.bin')
150
+ const abSpy = vi.spyOn(file, 'arrayBuffer')
151
+ const h = await calculateFileChecksum(file, 64) // size 64, boundary 64 -> inline
152
+ expect(h).toBe(await subtleHex(bytes))
153
+ expect(abSpy).toHaveBeenCalledTimes(1)
154
+ })
155
+
156
+ test('file below the boundary uses the inline Web Crypto path', async () => {
157
+ const bytes = new Uint8Array(32)
158
+ for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 3) & 0xff
159
+ const file = new File([bytes], 'small.bin')
160
+ const abSpy = vi.spyOn(file, 'arrayBuffer')
161
+ const h = await calculateFileChecksum(file, 64) // size 32 < boundary 64
162
+ expect(h).toBe(await subtleHex(bytes))
163
+ expect(abSpy).toHaveBeenCalledTimes(1)
164
+ })
165
+
166
+ test('file above the boundary is hashed incrementally and matches Web Crypto', async () => {
167
+ const bytes = new Uint8Array(128)
168
+ for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 7) & 0xff
169
+ const file = new File([bytes], 'big.bin')
170
+ // No Worker in the test realm -> inline-incremental over slices.
171
+ const h = await calculateFileChecksum(file, 16) // size 128 > boundary 16
172
+ expect(h).toBe(await subtleHex(bytes))
173
+ })
174
+
175
+ test('incremental path is stable across boundary/chunk splits', async () => {
176
+ const bytes = new Uint8Array(1000)
177
+ for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 31 + 7) & 0xff
178
+ const file = new File([bytes], 'span.bin')
179
+ const expected = await subtleHex(bytes)
180
+ // Force incremental with several different sub-cap boundaries.
181
+ for (const boundary of [1, 7, 13, 64, 100]) {
182
+ const h = await calculateFileChecksum(file, boundary)
183
+ expect(h).toBe(expected)
184
+ }
185
+ })
186
+
187
+ test('maxSize of 0 or null falls back to the default boundary', async () => {
188
+ const file = new File([new Uint8Array([1, 2, 3])], 'tiny.bin')
189
+ const h = await calculateFileChecksum(file, 0)
190
+ expect(h).toMatch(/^[0-9a-f]{64}$/)
191
+ })
138
192
  })
@@ -0,0 +1,108 @@
1
+ import { afterEach, describe, expect, test, vi } from 'vitest'
2
+ import { createFetchTransport } from '../core/contracts/transport'
3
+ import { UploadAbortError } from '../core/contracts/transport/transport.libs'
4
+
5
+ const URL_OK = 'https://up.example.com/o'
6
+
7
+ function stubFetch(impl: (url: string, init: RequestInit) => Promise<Response> | Response) {
8
+ const fn = vi.fn(impl)
9
+ vi.stubGlobal('fetch', fn)
10
+ return fn
11
+ }
12
+
13
+ afterEach(() => {
14
+ vi.unstubAllGlobals()
15
+ })
16
+
17
+ describe('createFetchTransport.put', () => {
18
+ test('PUTs the body and returns the ETag', async () => {
19
+ const fetchMock = stubFetch(() => new Response(null, { status: 200, headers: { ETag: '"deadbeef"' } }))
20
+ const t = createFetchTransport()
21
+ const onProgress = vi.fn()
22
+ const out = await t.put({
23
+ url: URL_OK,
24
+ body: new Blob([new Uint8Array(10)]),
25
+ headers: { 'Content-Type': 'application/octet-stream' },
26
+ signal: new AbortController().signal,
27
+ onProgress,
28
+ })
29
+
30
+ expect(out.etag).toBe('deadbeef')
31
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
32
+ expect(url).toBe(URL_OK)
33
+ expect(init.method).toBe('PUT')
34
+ // Terminal progress tick.
35
+ expect(onProgress).toHaveBeenCalledWith(10, 10)
36
+ })
37
+
38
+ test('rejects non-2xx with the status', async () => {
39
+ stubFetch(() => new Response('nope', { status: 500 }))
40
+ const t = createFetchTransport()
41
+ await expect(t.put({ url: URL_OK, body: new Blob(['x']), signal: new AbortController().signal })).rejects.toThrow(
42
+ /status 500/,
43
+ )
44
+ })
45
+
46
+ test('rejects a relative URL', async () => {
47
+ stubFetch(() => new Response(null, { status: 200 }))
48
+ const t = createFetchTransport()
49
+ await expect(
50
+ t.put({ url: '/relative', body: new Blob(['x']), signal: new AbortController().signal }),
51
+ ).rejects.toThrow(/absolute URL/)
52
+ })
53
+
54
+ test('maps an aborted signal to UploadAbortError', async () => {
55
+ stubFetch(() => new Response(null, { status: 200 }))
56
+ const controller = new AbortController()
57
+ controller.abort('cancel')
58
+ const t = createFetchTransport()
59
+ await expect(t.put({ url: URL_OK, body: new Blob(['x']), signal: controller.signal })).rejects.toBeInstanceOf(
60
+ UploadAbortError,
61
+ )
62
+ })
63
+ })
64
+
65
+ describe('createFetchTransport.postForm', () => {
66
+ test('sends multipart form data', async () => {
67
+ const fetchMock = stubFetch(() => new Response(null, { status: 204 }))
68
+ const t = createFetchTransport()
69
+ await t.postForm({
70
+ url: URL_OK,
71
+ fields: { key: 'v', policy: 'p' },
72
+ file: new File(['abc'], 'a.txt'),
73
+ signal: new AbortController().signal,
74
+ })
75
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
76
+ expect(init.method).toBe('POST')
77
+ expect(init.body).toBeInstanceOf(FormData)
78
+ })
79
+ })
80
+
81
+ describe('createFetchTransport.get', () => {
82
+ test('streams the body and reports incremental progress', async () => {
83
+ const payload = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])
84
+ stubFetch(
85
+ () =>
86
+ new Response(payload, {
87
+ status: 200,
88
+ headers: { 'Content-Length': String(payload.length), 'Content-Type': 'application/octet-stream' },
89
+ }),
90
+ )
91
+ const t = createFetchTransport()
92
+ const onProgress = vi.fn()
93
+ const out = await t.get?.({ url: URL_OK, signal: new AbortController().signal, onProgress })
94
+ expect(out?.status).toBe(200)
95
+ const bytes = new Uint8Array(await out!.blob.arrayBuffer())
96
+ expect(Array.from(bytes)).toEqual(Array.from(payload))
97
+ // Final progress equals total.
98
+ const last = onProgress.mock.calls.at(-1)
99
+ expect(last?.[0]).toBe(payload.length)
100
+ expect(last?.[1]).toBe(payload.length)
101
+ })
102
+
103
+ test('rejects a failed download', async () => {
104
+ stubFetch(() => new Response('nope', { status: 404 }))
105
+ const t = createFetchTransport()
106
+ await expect(t.get?.({ url: URL_OK, signal: new AbortController().signal })).rejects.toThrow(/status 404/)
107
+ })
108
+ })
@@ -89,7 +89,7 @@ describe('createXHRTransport.put', () => {
89
89
  headers: { 'x-foo': 'bar' },
90
90
  })
91
91
 
92
- const xhr = MockXHR.instances[0]
92
+ const xhr = MockXHR.instances[0]!
93
93
  expect(xhr.method).toBe('PUT')
94
94
  expect(xhr.url).toBe('https://example.com/part')
95
95
  expect(xhr.headers['x-foo']).toBe('bar')
@@ -104,7 +104,7 @@ describe('createXHRTransport.put', () => {
104
104
  const ctl = new AbortController()
105
105
  const promise = t.put({ url: 'https://example.com/part', body: new Blob(['x']), signal: ctl.signal })
106
106
 
107
- const xhr = MockXHR.instances[0]
107
+ const xhr = MockXHR.instances[0]!
108
108
  xhr.fireSuccess(503)
109
109
  await expect(promise).rejects.toThrow(/status 503/)
110
110
  })
@@ -114,7 +114,7 @@ describe('createXHRTransport.put', () => {
114
114
  const ctl = new AbortController()
115
115
  const promise = t.put({ url: 'https://example.com/part', body: new Blob(['x']), signal: ctl.signal })
116
116
 
117
- const xhr = MockXHR.instances[0]
117
+ const xhr = MockXHR.instances[0]!
118
118
  xhr.fireError(0)
119
119
  await expect(promise).rejects.toThrow(/CORS/)
120
120
  })
@@ -148,7 +148,7 @@ describe('createXHRTransport.put', () => {
148
148
  onProgress,
149
149
  })
150
150
 
151
- const xhr = MockXHR.instances[0]
151
+ const xhr = MockXHR.instances[0]!
152
152
  xhr.upload.onprogress?.({ lengthComputable: true, loaded: 5, total: 10 } as ProgressEvent)
153
153
  expect(onProgress).toHaveBeenCalledWith(5, 10)
154
154
 
@@ -169,7 +169,7 @@ describe('createXHRTransport.postForm', () => {
169
169
  signal: ctl.signal,
170
170
  })
171
171
 
172
- const xhr = MockXHR.instances[0]
172
+ const xhr = MockXHR.instances[0]!
173
173
  expect(xhr.method).toBe('POST')
174
174
  expect(xhr.body).toBeInstanceOf(FormData)
175
175
  const form = xhr.body as FormData
@@ -0,0 +1,152 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { UploadEngineError } from '../errors'
3
+
4
+ const MALICIOUS_FILENAME = '<img src=x onerror=alert(1)>.png'
5
+
6
+ describe('UploadEngineError', () => {
7
+ test('message is static, does NOT contain the raw filename', () => {
8
+ const err = new UploadEngineError('upload_failed', { context: { filename: MALICIOUS_FILENAME } })
9
+ expect(err.message).not.toContain(MALICIOUS_FILENAME)
10
+ expect(err.message).not.toContain('<img')
11
+ expect(err.message).toBe('upload transfer failed')
12
+ })
13
+
14
+ test('context.filename carries the raw filename intact', () => {
15
+ const err = new UploadEngineError('upload_failed', { context: { filename: MALICIOUS_FILENAME } })
16
+ expect(err.context?.['filename']).toBe(MALICIOUS_FILENAME)
17
+ })
18
+
19
+ test('is an instance of Error and UploadEngineError; code is preserved', () => {
20
+ const err = new UploadEngineError('intent_failed')
21
+ expect(err).toBeInstanceOf(Error)
22
+ expect(err).toBeInstanceOf(UploadEngineError)
23
+ expect(err.code).toBe('intent_failed')
24
+ expect(err.name).toBe('UploadEngineError')
25
+ })
26
+
27
+ test('cause is preserved on the ES2022 cause field', () => {
28
+ const original = new Error('boom')
29
+ const err = new UploadEngineError('upload_failed', { cause: original })
30
+ expect(err.cause).toBe(original)
31
+ })
32
+
33
+ test('unknown code falls back to a generic static message', () => {
34
+ const err = new UploadEngineError('something_new')
35
+ expect(err.message).toBe('upload error (something_new)')
36
+ expect(err.code).toBe('something_new')
37
+ })
38
+ })
39
+
40
+ describe('engine handlers filename-in-message inverse assertion', () => {
41
+ // The legacy contract was: `error.message.includes(filename)`. The new
42
+ // contract is the opposite: the filename MUST NOT appear in `message`. The
43
+ // handlers no longer interpolate filename into `message`; verifying that by
44
+ // exercising the handlers directly is covered by the integration suite, but
45
+ // we keep the contract pinned here so future refactors stay safe.
46
+ test('a structured error keeps the message clean and surfaces filename only on context', () => {
47
+ const base = { code: 'unknown' as const, message: 'Unknown error', retryable: false }
48
+ const error = {
49
+ ...base,
50
+ context: { filename: MALICIOUS_FILENAME, size: 12, purpose: 'avatar' },
51
+ }
52
+ expect(error.message).not.toContain(MALICIOUS_FILENAME)
53
+ expect(error.message).not.toContain('<img')
54
+ expect(error.context['filename']).toBe(MALICIOUS_FILENAME)
55
+ })
56
+ })
57
+
58
+ import {
59
+ UploadAbortError,
60
+ UploadAuthError,
61
+ UploadHttpError,
62
+ UploadNetworkError,
63
+ UploadRateLimitError,
64
+ UploadServerError,
65
+ UploadStrategyMissingError,
66
+ UploadTimeoutError,
67
+ UploadUnknownError,
68
+ UploadValidationError,
69
+ } from '../errors'
70
+
71
+ describe('UploadEngineError subclasses', () => {
72
+ test('UploadValidationError', () => {
73
+ const reason = { code: 'empty_file' as const }
74
+ const err = new UploadValidationError(reason)
75
+ expect(err).toBeInstanceOf(UploadEngineError)
76
+ expect(err.code).toBe('validation_failed')
77
+ expect(err.reason).toBe(reason)
78
+ expect(err.retryable).toBe(false)
79
+ })
80
+
81
+ test('UploadStrategyMissingError', () => {
82
+ const err = new UploadStrategyMissingError('custom')
83
+ expect(err).toBeInstanceOf(UploadEngineError)
84
+ expect(err.code).toBe('strategy_missing')
85
+ expect(err.strategy).toBe('custom')
86
+ expect(err.retryable).toBe(false)
87
+ })
88
+
89
+ test('UploadAbortError', () => {
90
+ const err = new UploadAbortError('pause')
91
+ expect(err).toBeInstanceOf(UploadEngineError)
92
+ expect(err.code).toBe('aborted')
93
+ expect(err.reason).toBe('pause')
94
+ expect(err.retryable).toBe(false)
95
+ })
96
+
97
+ test('UploadNetworkError', () => {
98
+ const err = new UploadNetworkError()
99
+ expect(err).toBeInstanceOf(UploadEngineError)
100
+ expect(err.code).toBe('network')
101
+ expect(err.retryable).toBe(true)
102
+ })
103
+
104
+ test('UploadHttpError', () => {
105
+ const err = new UploadHttpError(500, 'Internal Server Error', { statusText: 'Server Error' })
106
+ expect(err).toBeInstanceOf(UploadEngineError)
107
+ expect(err.code).toBe('http')
108
+ expect(err.status).toBe(500)
109
+ expect(err.statusText).toBe('Server Error')
110
+ expect(err.retryable).toBe(true)
111
+
112
+ const err400 = new UploadHttpError(400, 'Bad Request')
113
+ expect(err400.retryable).toBe(false)
114
+ })
115
+
116
+ test('UploadTimeoutError', () => {
117
+ const err = new UploadTimeoutError()
118
+ expect(err).toBeInstanceOf(UploadEngineError)
119
+ expect(err.code).toBe('timeout')
120
+ expect(err.retryable).toBe(true)
121
+ })
122
+
123
+ test('UploadAuthError', () => {
124
+ const err = new UploadAuthError()
125
+ expect(err).toBeInstanceOf(UploadEngineError)
126
+ expect(err.code).toBe('auth')
127
+ expect(err.retryable).toBe(false)
128
+ })
129
+
130
+ test('UploadRateLimitError', () => {
131
+ const err = new UploadRateLimitError('Too many requests', { retryAfterMs: 5000 })
132
+ expect(err).toBeInstanceOf(UploadEngineError)
133
+ expect(err.code).toBe('rate_limit')
134
+ expect(err.retryAfterMs).toBe(5000)
135
+ expect(err.retryable).toBe(true)
136
+ })
137
+
138
+ test('UploadServerError', () => {
139
+ const err = new UploadServerError('DB Error', { serverCode: 'DB_ERROR' })
140
+ expect(err).toBeInstanceOf(UploadEngineError)
141
+ expect(err.code).toBe('server')
142
+ expect(err.serverCode).toBe('DB_ERROR')
143
+ expect(err.retryable).toBe(true)
144
+ })
145
+
146
+ test('UploadUnknownError', () => {
147
+ const err = new UploadUnknownError('Something exploded')
148
+ expect(err).toBeInstanceOf(UploadEngineError)
149
+ expect(err.code).toBe('unknown')
150
+ expect(err.retryable).toBe(false)
151
+ })
152
+ })
@@ -0,0 +1,3 @@
1
+ export { resolveUploadConfig } from './engine/engine.libs'
2
+ export type { Store } from './engine/store'
3
+ export { createUploadStore } from './engine/store'