@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.
- package/README.md +170 -193
- 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 -51
- 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/{__test__ → __tests__}/emitter.test.ts +4 -4
- package/src/{__test__ → __tests__}/fingerprint.test.ts +1 -1
- package/src/{__test__ → __tests__}/guards.test.ts +15 -5
- package/src/__tests__/id.test.ts +67 -0
- package/src/__tests__/mime-sniff.test.ts +142 -0
- package/src/{__test__ → __tests__}/persistence.test.ts +43 -8
- 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/{__test__ → __tests__}/store-libs.test.ts +57 -3
- package/src/__tests__/transport-fetch.test.ts +108 -0
- package/src/{__test__ → __tests__}/transport.test.ts +5 -5
- package/src/core/__tests__/errors.test.ts +152 -0
- package/src/core/client.ts +3 -0
- package/src/core/contracts/contracts.types.ts +335 -141
- 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 +7 -8
- package/src/core/contracts/transport/transport.ts +91 -83
- 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 +367 -241
- package/src/core/engine/index.ts +3 -8
- package/src/core/engine/reducer.ts +89 -43
- package/src/core/engine/store/handlers/add-file.ts +39 -29
- 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 +49 -34
- 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 +184 -41
- package/src/core/engine/store/store.runtime.ts +45 -36
- 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 +7 -3
- 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 +7 -24
- 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 +68 -53
- package/src/react/uploader.types.ts +58 -61
- package/src/react/use-dropzone.tsx +226 -0
- package/src/react/use-uploader.tsx +98 -91
- 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 +206 -108
- package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
- package/src/strategies/post/index.ts +115 -33
- 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-CMkrP9DX.js +0 -2
- package/dist/index-C38Jmizu.d.ts +0 -1005
- package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
- package/src/__test__/effect-pool.test.ts +0 -5
- package/src/__test__/id.test.ts +0 -14
- package/src/__test__/store-integration.test.ts +0 -5
- package/src/core/client/client.ts +0 -50
- package/src/core/client/client.types.ts +0 -184
- 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 -202
- package/src/core/engine/outcome.types.ts +0 -9
- package/src/core/engine/progress.types.ts +0 -15
- package/src/core/engine/store/handlers.ts +0 -819
- package/src/core/engine/validation.ts +0 -216
- /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
|
+
})
|