@bsv/sdk 2.0.13 → 2.0.14
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 +2 -0
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/src/identity/IdentityClient.js +3 -3
- package/dist/cjs/src/identity/IdentityClient.js.map +1 -1
- package/dist/cjs/src/identity/types/index.js +1 -1
- package/dist/cjs/src/identity/types/index.js.map +1 -1
- package/dist/cjs/src/storage/StorageUploader.js +315 -96
- package/dist/cjs/src/storage/StorageUploader.js.map +1 -1
- package/dist/cjs/src/storage/index.js +3 -1
- package/dist/cjs/src/storage/index.js.map +1 -1
- package/dist/cjs/src/wallet/WERR_REVIEW_ACTIONS.js +2 -2
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/identity/IdentityClient.js +3 -3
- package/dist/esm/src/identity/IdentityClient.js.map +1 -1
- package/dist/esm/src/identity/types/index.js +1 -1
- package/dist/esm/src/identity/types/index.js.map +1 -1
- package/dist/esm/src/storage/StorageUploader.js +319 -95
- package/dist/esm/src/storage/StorageUploader.js.map +1 -1
- package/dist/esm/src/storage/index.js +1 -1
- package/dist/esm/src/storage/index.js.map +1 -1
- package/dist/esm/src/wallet/WERR_REVIEW_ACTIONS.js +2 -2
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/storage/StorageUploader.d.ts +94 -55
- package/dist/types/src/storage/StorageUploader.d.ts.map +1 -1
- package/dist/types/src/storage/index.d.ts +1 -1
- package/dist/types/src/storage/index.d.ts.map +1 -1
- package/dist/types/src/wallet/WERR_REVIEW_ACTIONS.d.ts +2 -2
- package/dist/types/src/wallet/Wallet.interfaces.d.ts +2 -2
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/dist/umd/bundle.js +4 -4
- package/dist/umd/bundle.js.map +1 -1
- package/docs/index.md +3 -1
- package/docs/reference/identity.md +1 -1
- package/docs/reference/storage.md +214 -85
- package/docs/reference/transaction.md +2 -2
- package/docs/reference/wallet.md +4 -4
- package/package.json +1 -1
- package/src/identity/IdentityClient.ts +3 -3
- package/src/identity/__tests/IdentityClient.additional.test.ts +3 -3
- package/src/identity/types/index.ts +1 -1
- package/src/storage/StorageUploader.ts +427 -105
- package/src/storage/__tests/StorageUploader.test.ts +881 -64
- package/src/storage/index.ts +1 -1
- package/src/wallet/WERR_REVIEW_ACTIONS.ts +2 -2
- package/src/wallet/Wallet.interfaces.ts +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { StorageUploader } from '../StorageUploader.js'
|
|
1
|
+
import { StorageUploader, DEFAULT_UHRP_SERVERS, RenewResiliencyError } from '../StorageUploader.js'
|
|
2
2
|
import * as StorageUtils from '../StorageUtils.js'
|
|
3
3
|
import WalletClient from '../../wallet/WalletClient.js'
|
|
4
4
|
import { createHash } from 'crypto'
|
|
@@ -6,15 +6,33 @@ import { createHash } from 'crypto'
|
|
|
6
6
|
/**
|
|
7
7
|
* A helper for converting a string to a number[] of UTF-8 bytes
|
|
8
8
|
*/
|
|
9
|
-
function stringToUtf8Array(str: string): number[] {
|
|
9
|
+
function stringToUtf8Array (str: string): number[] {
|
|
10
10
|
return Array.from(new TextEncoder().encode(str))
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Builds a JSON Response for mocked fetch calls.
|
|
15
|
+
*/
|
|
16
|
+
function jsonResponse (body: unknown, status = 200): Response {
|
|
17
|
+
return new Response(JSON.stringify(body), {
|
|
18
|
+
status,
|
|
19
|
+
headers: { 'Content-Type': 'application/json' }
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Normalizes whatever a caller passed to `fetch` into a URL string.
|
|
25
|
+
*/
|
|
26
|
+
function extractFetchURL (input: RequestInfo | URL): string {
|
|
27
|
+
if (typeof input === 'string') return input
|
|
28
|
+
if (input instanceof URL) return input.toString()
|
|
29
|
+
return input.url
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('StorageUploader — legacy single-host behavior', () => {
|
|
14
33
|
let uploader: StorageUploader
|
|
15
34
|
let walletClient: WalletClient
|
|
16
35
|
|
|
17
|
-
// We'll have TWO spies:
|
|
18
36
|
let authFetchSpy: jest.SpyInstance
|
|
19
37
|
let globalFetchSpy: jest.SpiedFunction<typeof global.fetch>
|
|
20
38
|
|
|
@@ -25,12 +43,10 @@ describe('StorageUploader Tests', () => {
|
|
|
25
43
|
wallet: walletClient
|
|
26
44
|
})
|
|
27
45
|
|
|
28
|
-
// 1) Spy on the "authFetch.fetch" calls for /find, /list, /renew
|
|
29
46
|
authFetchSpy = jest
|
|
30
|
-
.spyOn(uploader
|
|
47
|
+
.spyOn((uploader as any).authFetch, 'fetch')
|
|
31
48
|
.mockResolvedValue(new Response(null, { status: 200 }))
|
|
32
49
|
|
|
33
|
-
// 2) Spy on the global "fetch" calls for file upload (uploadFile)
|
|
34
50
|
globalFetchSpy = jest
|
|
35
51
|
.spyOn(global, 'fetch')
|
|
36
52
|
.mockResolvedValue(new Response(null, { status: 200 }))
|
|
@@ -43,22 +59,35 @@ describe('StorageUploader Tests', () => {
|
|
|
43
59
|
it('should upload a file, produce a valid UHRP URL, and decode it to the known SHA-256', async () => {
|
|
44
60
|
const data = stringToUtf8Array('Hello, world!')
|
|
45
61
|
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
62
|
+
// Quote (global fetch) + PUT upload (global fetch)
|
|
63
|
+
globalFetchSpy
|
|
64
|
+
.mockResolvedValueOnce(jsonResponse({ quote: 42 }))
|
|
65
|
+
.mockResolvedValueOnce(new Response(null, { status: 200 }))
|
|
66
|
+
|
|
67
|
+
// /upload (authFetch)
|
|
68
|
+
authFetchSpy.mockResolvedValueOnce(jsonResponse({
|
|
69
|
+
status: 'success',
|
|
70
|
+
uploadURL: 'https://example-upload.com/put',
|
|
71
|
+
requiredHeaders: {},
|
|
72
|
+
amount: 42
|
|
73
|
+
}))
|
|
50
74
|
|
|
51
75
|
const result = await uploader.publishFile({
|
|
52
76
|
file: { data, type: 'text/plain' },
|
|
53
77
|
retentionPeriod: 7
|
|
54
78
|
})
|
|
55
79
|
|
|
56
|
-
//
|
|
57
|
-
expect(globalFetchSpy).toHaveBeenCalledTimes(
|
|
80
|
+
// One quote fetch + one PUT upload
|
|
81
|
+
expect(globalFetchSpy).toHaveBeenCalledTimes(2)
|
|
82
|
+
expect(globalFetchSpy.mock.calls[0][0]).toBe('https://example.test.system/quote')
|
|
83
|
+
expect(globalFetchSpy.mock.calls[1][0]).toBe('https://example-upload.com/put')
|
|
84
|
+
|
|
85
|
+
expect(authFetchSpy).toHaveBeenCalledTimes(1)
|
|
86
|
+
expect(authFetchSpy.mock.calls[0][0]).toBe('https://example.test.system/upload')
|
|
58
87
|
|
|
59
|
-
// Check the result
|
|
60
88
|
expect(StorageUtils.isValidURL(result.uhrpURL)).toBe(true)
|
|
61
89
|
expect(result.published).toBe(true)
|
|
90
|
+
expect(result.hostedBy).toEqual(['https://example.test.system'])
|
|
62
91
|
|
|
63
92
|
const url = StorageUtils.getHashFromURL(result.uhrpURL)
|
|
64
93
|
const firstFour = url.slice(0, 4)
|
|
@@ -72,9 +101,16 @@ describe('StorageUploader Tests', () => {
|
|
|
72
101
|
const data = new Uint8Array(size)
|
|
73
102
|
for (let i = 0; i < size; i++) data[i] = i % 256
|
|
74
103
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
104
|
+
globalFetchSpy
|
|
105
|
+
.mockResolvedValueOnce(jsonResponse({ quote: 100 }))
|
|
106
|
+
.mockResolvedValueOnce(new Response(null, { status: 200 }))
|
|
107
|
+
|
|
108
|
+
authFetchSpy.mockResolvedValueOnce(jsonResponse({
|
|
109
|
+
status: 'success',
|
|
110
|
+
uploadURL: 'https://example-upload.com/put',
|
|
111
|
+
requiredHeaders: {},
|
|
112
|
+
amount: 100
|
|
113
|
+
}))
|
|
78
114
|
|
|
79
115
|
const result = await uploader.publishFile({
|
|
80
116
|
file: { data, type: 'application/octet-stream' },
|
|
@@ -84,16 +120,20 @@ describe('StorageUploader Tests', () => {
|
|
|
84
120
|
const expectedHash = createHash('sha256').update(data).digest()
|
|
85
121
|
const urlHash = StorageUtils.getHashFromURL(result.uhrpURL)
|
|
86
122
|
expect(Buffer.from(urlHash)).toEqual(expectedHash)
|
|
123
|
+
expect(result.hostedBy).toEqual(['https://example.test.system'])
|
|
87
124
|
})
|
|
88
125
|
|
|
89
126
|
it('should throw if the upload fails with HTTP 500', async () => {
|
|
90
|
-
|
|
91
|
-
|
|
127
|
+
globalFetchSpy
|
|
128
|
+
.mockResolvedValueOnce(jsonResponse({ quote: 42 }))
|
|
129
|
+
.mockResolvedValueOnce(new Response(null, { status: 500 }))
|
|
92
130
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
uploadURL: 'https://example-upload.com/put'
|
|
96
|
-
|
|
131
|
+
authFetchSpy.mockResolvedValueOnce(jsonResponse({
|
|
132
|
+
status: 'success',
|
|
133
|
+
uploadURL: 'https://example-upload.com/put',
|
|
134
|
+
requiredHeaders: {},
|
|
135
|
+
amount: 42
|
|
136
|
+
}))
|
|
97
137
|
|
|
98
138
|
const failingData = stringToUtf8Array('failing data')
|
|
99
139
|
|
|
@@ -102,24 +142,20 @@ describe('StorageUploader Tests', () => {
|
|
|
102
142
|
file: { data: failingData, type: 'text/plain' },
|
|
103
143
|
retentionPeriod: 30
|
|
104
144
|
})
|
|
105
|
-
).rejects.toThrow(
|
|
145
|
+
).rejects.toThrow(/Resiliency threshold of 1 could not be met/)
|
|
106
146
|
})
|
|
107
147
|
|
|
108
148
|
it('should find a file and return metadata', async () => {
|
|
109
|
-
// This route goes through authFetch, not global fetch
|
|
110
149
|
authFetchSpy.mockResolvedValueOnce(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}),
|
|
121
|
-
{ status: 200 }
|
|
122
|
-
)
|
|
150
|
+
jsonResponse({
|
|
151
|
+
status: 'success',
|
|
152
|
+
data: {
|
|
153
|
+
name: 'cdn/abc123',
|
|
154
|
+
size: '1024',
|
|
155
|
+
mimeType: 'text/plain',
|
|
156
|
+
expiryTime: 123456
|
|
157
|
+
}
|
|
158
|
+
})
|
|
123
159
|
)
|
|
124
160
|
|
|
125
161
|
const fileData = await uploader.findFile('uhrp://some-hash')
|
|
@@ -132,10 +168,7 @@ describe('StorageUploader Tests', () => {
|
|
|
132
168
|
|
|
133
169
|
it('should throw an error if findFile returns an error status', async () => {
|
|
134
170
|
authFetchSpy.mockResolvedValueOnce(
|
|
135
|
-
|
|
136
|
-
JSON.stringify({ status: 'error', code: 'ERR_NOT_FOUND', description: 'File not found' }),
|
|
137
|
-
{ status: 200 }
|
|
138
|
-
)
|
|
171
|
+
jsonResponse({ status: 'error', code: 'ERR_NOT_FOUND', description: 'File not found' })
|
|
139
172
|
)
|
|
140
173
|
|
|
141
174
|
await expect(uploader.findFile('uhrp://unknown-hash'))
|
|
@@ -144,16 +177,12 @@ describe('StorageUploader Tests', () => {
|
|
|
144
177
|
})
|
|
145
178
|
|
|
146
179
|
it('should list user uploads successfully', async () => {
|
|
147
|
-
// /list uses authFetch
|
|
148
180
|
const mockUploads = [
|
|
149
181
|
{ uhrpUrl: 'uhrp://hash1', expiryTime: 111111 },
|
|
150
182
|
{ uhrpUrl: 'uhrp://hash2', expiryTime: 222222 }
|
|
151
183
|
]
|
|
152
184
|
authFetchSpy.mockResolvedValueOnce(
|
|
153
|
-
|
|
154
|
-
JSON.stringify({ status: 'success', uploads: mockUploads }),
|
|
155
|
-
{ status: 200 }
|
|
156
|
-
)
|
|
185
|
+
jsonResponse({ status: 'success', uploads: mockUploads })
|
|
157
186
|
)
|
|
158
187
|
|
|
159
188
|
const result = await uploader.listUploads()
|
|
@@ -163,10 +192,7 @@ describe('StorageUploader Tests', () => {
|
|
|
163
192
|
|
|
164
193
|
it('should throw an error if listUploads returns an error', async () => {
|
|
165
194
|
authFetchSpy.mockResolvedValueOnce(
|
|
166
|
-
|
|
167
|
-
JSON.stringify({ status: 'error', code: 'ERR_INTERNAL', description: 'Something broke' }),
|
|
168
|
-
{ status: 200 }
|
|
169
|
-
)
|
|
195
|
+
jsonResponse({ status: 'error', code: 'ERR_INTERNAL', description: 'Something broke' })
|
|
170
196
|
)
|
|
171
197
|
|
|
172
198
|
await expect(uploader.listUploads()).rejects.toThrow(
|
|
@@ -175,17 +201,13 @@ describe('StorageUploader Tests', () => {
|
|
|
175
201
|
})
|
|
176
202
|
|
|
177
203
|
it('should renew a file and return the new expiry info', async () => {
|
|
178
|
-
// /renew uses authFetch
|
|
179
204
|
authFetchSpy.mockResolvedValueOnce(
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}),
|
|
187
|
-
{ status: 200 }
|
|
188
|
-
)
|
|
205
|
+
jsonResponse({
|
|
206
|
+
status: 'success',
|
|
207
|
+
prevExpiryTime: 123,
|
|
208
|
+
newExpiryTime: 456,
|
|
209
|
+
amount: 99
|
|
210
|
+
})
|
|
189
211
|
)
|
|
190
212
|
|
|
191
213
|
const renewal = await uploader.renewFile('uhrp://some-hash', 30)
|
|
@@ -198,10 +220,7 @@ describe('StorageUploader Tests', () => {
|
|
|
198
220
|
|
|
199
221
|
it('should throw an error if renewFile returns error status JSON', async () => {
|
|
200
222
|
authFetchSpy.mockResolvedValueOnce(
|
|
201
|
-
|
|
202
|
-
JSON.stringify({ status: 'error', code: 'ERR_CANT_RENEW', description: 'Failed to renew' }),
|
|
203
|
-
{ status: 200 }
|
|
204
|
-
)
|
|
223
|
+
jsonResponse({ status: 'error', code: 'ERR_CANT_RENEW', description: 'Failed to renew' })
|
|
205
224
|
)
|
|
206
225
|
|
|
207
226
|
await expect(uploader.renewFile('uhrp://some-other-hash', 15))
|
|
@@ -217,3 +236,801 @@ describe('StorageUploader Tests', () => {
|
|
|
217
236
|
.toThrow('renewFile request failed: HTTP 404')
|
|
218
237
|
})
|
|
219
238
|
})
|
|
239
|
+
|
|
240
|
+
describe('StorageUploader — multi-provider behavior', () => {
|
|
241
|
+
let walletClient: WalletClient
|
|
242
|
+
|
|
243
|
+
beforeEach(() => {
|
|
244
|
+
walletClient = new WalletClient('json-api', 'non-admin.com')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
afterEach(() => {
|
|
248
|
+
jest.restoreAllMocks()
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Sets up URL-routed mocks for the quote, upload, and PUT steps across any
|
|
253
|
+
* number of providers. Returns the two spies for assertion.
|
|
254
|
+
*/
|
|
255
|
+
function wireMocks (
|
|
256
|
+
uploader: StorageUploader,
|
|
257
|
+
quotes: Record<string, number | 'error'>,
|
|
258
|
+
uploadOutcomes: Record<string, 'ok' | 'fail'> = {}
|
|
259
|
+
): {
|
|
260
|
+
authFetchSpy: jest.SpyInstance
|
|
261
|
+
globalFetchSpy: jest.SpiedFunction<typeof global.fetch>
|
|
262
|
+
putCalls: string[]
|
|
263
|
+
quoteCalls: string[]
|
|
264
|
+
uploadCalls: string[]
|
|
265
|
+
} {
|
|
266
|
+
const putCalls: string[] = []
|
|
267
|
+
const quoteCalls: string[] = []
|
|
268
|
+
const uploadCalls: string[] = []
|
|
269
|
+
|
|
270
|
+
const globalFetchSpy = jest
|
|
271
|
+
.spyOn(global, 'fetch')
|
|
272
|
+
.mockImplementation(async (input: RequestInfo | URL) => {
|
|
273
|
+
const url = extractFetchURL(input)
|
|
274
|
+
// Quote requests hit {host}/quote
|
|
275
|
+
if (url.endsWith('/quote')) {
|
|
276
|
+
quoteCalls.push(url)
|
|
277
|
+
const host = url.slice(0, -'/quote'.length)
|
|
278
|
+
const quote = quotes[host]
|
|
279
|
+
if (quote === undefined || quote === 'error') {
|
|
280
|
+
return new Response(null, { status: 500 })
|
|
281
|
+
}
|
|
282
|
+
return jsonResponse({ quote })
|
|
283
|
+
}
|
|
284
|
+
// Otherwise we treat it as the PUT upload.
|
|
285
|
+
putCalls.push(url)
|
|
286
|
+
const outcome = uploadOutcomes[url]
|
|
287
|
+
if (outcome === 'fail') {
|
|
288
|
+
return new Response(null, { status: 500 })
|
|
289
|
+
}
|
|
290
|
+
return new Response(null, { status: 200 })
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
const authFetchSpy = jest
|
|
294
|
+
.spyOn((uploader as any).authFetch, 'fetch')
|
|
295
|
+
.mockImplementation(async (input: RequestInfo | URL) => {
|
|
296
|
+
const url = extractFetchURL(input)
|
|
297
|
+
if (url.endsWith('/upload')) {
|
|
298
|
+
uploadCalls.push(url)
|
|
299
|
+
const host = url.slice(0, -'/upload'.length)
|
|
300
|
+
return jsonResponse({
|
|
301
|
+
status: 'success',
|
|
302
|
+
uploadURL: `${host}/put`,
|
|
303
|
+
requiredHeaders: {},
|
|
304
|
+
amount: quotes[host]
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
return new Response(null, { status: 404 })
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
return { authFetchSpy, globalFetchSpy, putCalls, quoteCalls, uploadCalls }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
it('defaults to DEFAULT_UHRP_SERVERS when no hosts are configured', () => {
|
|
314
|
+
const uploader = new StorageUploader({ wallet: walletClient })
|
|
315
|
+
expect((uploader as any).hosts).toEqual(DEFAULT_UHRP_SERVERS)
|
|
316
|
+
expect((uploader as any).resilienceLevel).toBe(1)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('clamps resilienceLevel to 1 for legacy storageURL callers', () => {
|
|
320
|
+
const uploader = new StorageUploader({
|
|
321
|
+
storageURL: 'https://legacy.example',
|
|
322
|
+
wallet: walletClient
|
|
323
|
+
})
|
|
324
|
+
expect((uploader as any).hosts).toEqual(['https://legacy.example'])
|
|
325
|
+
expect((uploader as any).resilienceLevel).toBe(1)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('uses storageURLs over storageURL when both are provided', () => {
|
|
329
|
+
const uploader = new StorageUploader({
|
|
330
|
+
storageURL: 'https://legacy.example',
|
|
331
|
+
storageURLs: ['https://a.example', 'https://b.example'],
|
|
332
|
+
wallet: walletClient
|
|
333
|
+
})
|
|
334
|
+
expect((uploader as any).hosts).toEqual(['https://a.example', 'https://b.example'])
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('throws if resilienceLevel is not a positive integer', () => {
|
|
338
|
+
expect(() => new StorageUploader({
|
|
339
|
+
storageURLs: ['https://a.example'],
|
|
340
|
+
resilienceLevel: 0,
|
|
341
|
+
wallet: walletClient
|
|
342
|
+
})).toThrow(/positive integer/)
|
|
343
|
+
|
|
344
|
+
expect(() => new StorageUploader({
|
|
345
|
+
storageURLs: ['https://a.example'],
|
|
346
|
+
resilienceLevel: 1.5,
|
|
347
|
+
wallet: walletClient
|
|
348
|
+
})).toThrow(/positive integer/)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('throws if storageURLs is an empty array', () => {
|
|
352
|
+
expect(() => new StorageUploader({
|
|
353
|
+
storageURLs: [],
|
|
354
|
+
wallet: walletClient
|
|
355
|
+
})).toThrow(/at least one/)
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('sorts quotes by price and uploads to the cheapest N', async () => {
|
|
359
|
+
const hosts = ['https://a.example', 'https://b.example', 'https://c.example']
|
|
360
|
+
const uploader = new StorageUploader({
|
|
361
|
+
storageURLs: hosts,
|
|
362
|
+
resilienceLevel: 2,
|
|
363
|
+
wallet: walletClient
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
const { quoteCalls, uploadCalls, putCalls } = wireMocks(uploader, {
|
|
367
|
+
'https://a.example': 300,
|
|
368
|
+
'https://b.example': 100,
|
|
369
|
+
'https://c.example': 200
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
const data = stringToUtf8Array('multi-host payload')
|
|
373
|
+
const result = await uploader.publishFile({
|
|
374
|
+
file: { data, type: 'text/plain' },
|
|
375
|
+
retentionPeriod: 60
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
// All three quotes requested (up to 2 * resilienceLevel = 4 allowed, but we only have 3 hosts).
|
|
379
|
+
expect(quoteCalls.sort()).toEqual([
|
|
380
|
+
'https://a.example/quote',
|
|
381
|
+
'https://b.example/quote',
|
|
382
|
+
'https://c.example/quote'
|
|
383
|
+
])
|
|
384
|
+
// Uploads happen in ascending price order: b (100), c (200).
|
|
385
|
+
expect(uploadCalls).toEqual([
|
|
386
|
+
'https://b.example/upload',
|
|
387
|
+
'https://c.example/upload'
|
|
388
|
+
])
|
|
389
|
+
expect(putCalls).toEqual([
|
|
390
|
+
'https://b.example/put',
|
|
391
|
+
'https://c.example/put'
|
|
392
|
+
])
|
|
393
|
+
expect(result.hostedBy).toEqual(['https://b.example', 'https://c.example'])
|
|
394
|
+
expect(result.published).toBe(true)
|
|
395
|
+
expect(StorageUtils.isValidURL(result.uhrpURL)).toBe(true)
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('falls through to the next-cheapest host when a paid upload fails', async () => {
|
|
399
|
+
const hosts = ['https://a.example', 'https://b.example', 'https://c.example']
|
|
400
|
+
const uploader = new StorageUploader({
|
|
401
|
+
storageURLs: hosts,
|
|
402
|
+
resilienceLevel: 2,
|
|
403
|
+
wallet: walletClient
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
const { uploadCalls, putCalls } = wireMocks(
|
|
407
|
+
uploader,
|
|
408
|
+
{
|
|
409
|
+
'https://a.example': 300,
|
|
410
|
+
'https://b.example': 100,
|
|
411
|
+
'https://c.example': 200
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
// Cheapest host's PUT fails; must fall through to c (200) then a (300).
|
|
415
|
+
'https://b.example/put': 'fail'
|
|
416
|
+
}
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
const data = stringToUtf8Array('multi-host payload')
|
|
420
|
+
const result = await uploader.publishFile({
|
|
421
|
+
file: { data, type: 'text/plain' },
|
|
422
|
+
retentionPeriod: 60
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
expect(uploadCalls).toEqual([
|
|
426
|
+
'https://b.example/upload',
|
|
427
|
+
'https://c.example/upload',
|
|
428
|
+
'https://a.example/upload'
|
|
429
|
+
])
|
|
430
|
+
expect(putCalls).toEqual([
|
|
431
|
+
'https://b.example/put',
|
|
432
|
+
'https://c.example/put',
|
|
433
|
+
'https://a.example/put'
|
|
434
|
+
])
|
|
435
|
+
expect(result.hostedBy).toEqual(['https://c.example', 'https://a.example'])
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it('throws when fewer providers respond with quotes than resilienceLevel', async () => {
|
|
439
|
+
const hosts = ['https://a.example', 'https://b.example', 'https://c.example']
|
|
440
|
+
const uploader = new StorageUploader({
|
|
441
|
+
storageURLs: hosts,
|
|
442
|
+
resilienceLevel: 3,
|
|
443
|
+
wallet: walletClient
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
wireMocks(uploader, {
|
|
447
|
+
'https://a.example': 100,
|
|
448
|
+
'https://b.example': 'error',
|
|
449
|
+
'https://c.example': 'error'
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
const data = stringToUtf8Array('fails to meet threshold')
|
|
453
|
+
await expect(
|
|
454
|
+
uploader.publishFile({
|
|
455
|
+
file: { data, type: 'text/plain' },
|
|
456
|
+
retentionPeriod: 60
|
|
457
|
+
})
|
|
458
|
+
).rejects.toThrow(/Resiliency threshold of 3 could not be met: only 1 of 3 provider\(s\) responded/)
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('throws when remaining quotes cannot cover the threshold after upload failures', async () => {
|
|
462
|
+
const hosts = ['https://a.example', 'https://b.example']
|
|
463
|
+
const uploader = new StorageUploader({
|
|
464
|
+
storageURLs: hosts,
|
|
465
|
+
resilienceLevel: 2,
|
|
466
|
+
wallet: walletClient
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
wireMocks(
|
|
470
|
+
uploader,
|
|
471
|
+
{
|
|
472
|
+
'https://a.example': 100,
|
|
473
|
+
'https://b.example': 200
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
'https://a.example/put': 'fail',
|
|
477
|
+
'https://b.example/put': 'fail'
|
|
478
|
+
}
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
const data = stringToUtf8Array('all uploads fail')
|
|
482
|
+
await expect(
|
|
483
|
+
uploader.publishFile({
|
|
484
|
+
file: { data, type: 'text/plain' },
|
|
485
|
+
retentionPeriod: 60
|
|
486
|
+
})
|
|
487
|
+
).rejects.toThrow(/only 0 upload\(s\) succeeded/)
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
it('returns the same UHRP URL regardless of which providers hosted the file', async () => {
|
|
491
|
+
const hosts = ['https://a.example', 'https://b.example']
|
|
492
|
+
const uploader = new StorageUploader({
|
|
493
|
+
storageURLs: hosts,
|
|
494
|
+
resilienceLevel: 2,
|
|
495
|
+
wallet: walletClient
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
wireMocks(uploader, {
|
|
499
|
+
'https://a.example': 50,
|
|
500
|
+
'https://b.example': 75
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
const data = stringToUtf8Array('content addressed')
|
|
504
|
+
const result = await uploader.publishFile({
|
|
505
|
+
file: { data, type: 'text/plain' },
|
|
506
|
+
retentionPeriod: 60
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
// The UHRP URL is derived from the file hash, not from any host.
|
|
510
|
+
const expected = StorageUtils.getURLForFile(Uint8Array.from(data))
|
|
511
|
+
expect(result.uhrpURL).toBe(expected)
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
it('estimateCost returns sorted quotes and the total for the cheapest resilienceLevel hosts', async () => {
|
|
515
|
+
const hosts = ['https://a.example', 'https://b.example', 'https://c.example']
|
|
516
|
+
const uploader = new StorageUploader({
|
|
517
|
+
storageURLs: hosts,
|
|
518
|
+
resilienceLevel: 2,
|
|
519
|
+
wallet: walletClient
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
wireMocks(uploader, {
|
|
523
|
+
'https://a.example': 300,
|
|
524
|
+
'https://b.example': 100,
|
|
525
|
+
'https://c.example': 200
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
const estimate = await uploader.estimateCost({ fileSize: 1024, retentionPeriod: 60 })
|
|
529
|
+
|
|
530
|
+
expect(estimate.resilienceLevel).toBe(2)
|
|
531
|
+
expect(estimate.meetsResilienceThreshold).toBe(true)
|
|
532
|
+
expect(estimate.quotes).toEqual([
|
|
533
|
+
{ host: 'https://b.example', amount: 100 },
|
|
534
|
+
{ host: 'https://c.example', amount: 200 },
|
|
535
|
+
{ host: 'https://a.example', amount: 300 }
|
|
536
|
+
])
|
|
537
|
+
// Cheapest 2 = 100 + 200
|
|
538
|
+
expect(estimate.totalForResilience).toBe(300)
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it('estimateCost reports when the resilience threshold cannot be met', async () => {
|
|
542
|
+
const hosts = ['https://a.example', 'https://b.example', 'https://c.example']
|
|
543
|
+
const uploader = new StorageUploader({
|
|
544
|
+
storageURLs: hosts,
|
|
545
|
+
resilienceLevel: 3,
|
|
546
|
+
wallet: walletClient
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
// Only one host returns a quote.
|
|
550
|
+
wireMocks(uploader, {
|
|
551
|
+
'https://a.example': 100,
|
|
552
|
+
'https://b.example': 'error',
|
|
553
|
+
'https://c.example': 'error'
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
const estimate = await uploader.estimateCost({ fileSize: 1024, retentionPeriod: 60 })
|
|
557
|
+
|
|
558
|
+
expect(estimate.meetsResilienceThreshold).toBe(false)
|
|
559
|
+
expect(estimate.quotes).toHaveLength(1)
|
|
560
|
+
// Partial total so callers can still see the cost of what was collected.
|
|
561
|
+
expect(estimate.totalForResilience).toBe(100)
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
it('estimateCost does not trigger any authenticated /upload requests', async () => {
|
|
565
|
+
const hosts = ['https://a.example', 'https://b.example']
|
|
566
|
+
const uploader = new StorageUploader({
|
|
567
|
+
storageURLs: hosts,
|
|
568
|
+
resilienceLevel: 2,
|
|
569
|
+
wallet: walletClient
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
const { authFetchSpy, globalFetchSpy } = wireMocks(uploader, {
|
|
573
|
+
'https://a.example': 50,
|
|
574
|
+
'https://b.example': 75
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
await uploader.estimateCost({ fileSize: 1024, retentionPeriod: 60 })
|
|
578
|
+
|
|
579
|
+
// Only the free /quote endpoint is used (via global fetch).
|
|
580
|
+
expect(globalFetchSpy).toHaveBeenCalledTimes(2)
|
|
581
|
+
expect(authFetchSpy).not.toHaveBeenCalled()
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
it('stops quoting further hosts once 2 * resilienceLevel quotes are collected', async () => {
|
|
585
|
+
const hosts = [
|
|
586
|
+
'https://h1.example',
|
|
587
|
+
'https://h2.example',
|
|
588
|
+
'https://h3.example',
|
|
589
|
+
'https://h4.example',
|
|
590
|
+
'https://h5.example',
|
|
591
|
+
'https://h6.example',
|
|
592
|
+
'https://h7.example',
|
|
593
|
+
'https://h8.example'
|
|
594
|
+
]
|
|
595
|
+
const uploader = new StorageUploader({
|
|
596
|
+
storageURLs: hosts,
|
|
597
|
+
resilienceLevel: 2, // target = 4 quotes
|
|
598
|
+
wallet: walletClient
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
// Every host would return a valid quote if asked; we want to prove that
|
|
602
|
+
// hosts after the first batch of 4 are never contacted.
|
|
603
|
+
const quotes: Record<string, number> = {}
|
|
604
|
+
hosts.forEach((h, i) => { quotes[h] = 100 + i })
|
|
605
|
+
|
|
606
|
+
const { quoteCalls } = wireMocks(uploader, quotes)
|
|
607
|
+
|
|
608
|
+
await uploader.publishFile({
|
|
609
|
+
file: { data: stringToUtf8Array('bounded quoting'), type: 'text/plain' },
|
|
610
|
+
retentionPeriod: 60
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
// Only the first batch of 4 quote requests should have been issued.
|
|
614
|
+
expect(quoteCalls.sort()).toEqual([
|
|
615
|
+
'https://h1.example/quote',
|
|
616
|
+
'https://h2.example/quote',
|
|
617
|
+
'https://h3.example/quote',
|
|
618
|
+
'https://h4.example/quote'
|
|
619
|
+
])
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
it('advances to a later batch when the first batch cannot fill the quote quota', async () => {
|
|
623
|
+
const hosts = [
|
|
624
|
+
'https://h1.example',
|
|
625
|
+
'https://h2.example',
|
|
626
|
+
'https://h3.example',
|
|
627
|
+
'https://h4.example',
|
|
628
|
+
'https://h5.example',
|
|
629
|
+
'https://h6.example'
|
|
630
|
+
]
|
|
631
|
+
const uploader = new StorageUploader({
|
|
632
|
+
storageURLs: hosts,
|
|
633
|
+
resilienceLevel: 2, // target = 4 quotes
|
|
634
|
+
wallet: walletClient
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
// First batch of 4: only 2 valid quotes. Second batch of 2 fills the target.
|
|
638
|
+
const { quoteCalls } = wireMocks(uploader, {
|
|
639
|
+
'https://h1.example': 100,
|
|
640
|
+
'https://h2.example': 'error',
|
|
641
|
+
'https://h3.example': 200,
|
|
642
|
+
'https://h4.example': 'error',
|
|
643
|
+
'https://h5.example': 300,
|
|
644
|
+
'https://h6.example': 400
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
await uploader.publishFile({
|
|
648
|
+
file: { data: stringToUtf8Array('needs a second batch'), type: 'text/plain' },
|
|
649
|
+
retentionPeriod: 60
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
expect(quoteCalls.sort()).toEqual([
|
|
653
|
+
'https://h1.example/quote',
|
|
654
|
+
'https://h2.example/quote',
|
|
655
|
+
'https://h3.example/quote',
|
|
656
|
+
'https://h4.example/quote',
|
|
657
|
+
'https://h5.example/quote',
|
|
658
|
+
'https://h6.example/quote'
|
|
659
|
+
])
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
it('asks only for the remaining number of quotes in subsequent batches', async () => {
|
|
663
|
+
const hosts = [
|
|
664
|
+
'https://h1.example',
|
|
665
|
+
'https://h2.example',
|
|
666
|
+
'https://h3.example',
|
|
667
|
+
'https://h4.example',
|
|
668
|
+
'https://h5.example',
|
|
669
|
+
'https://h6.example',
|
|
670
|
+
'https://h7.example',
|
|
671
|
+
'https://h8.example'
|
|
672
|
+
]
|
|
673
|
+
const uploader = new StorageUploader({
|
|
674
|
+
storageURLs: hosts,
|
|
675
|
+
resilienceLevel: 2, // target = 4 quotes
|
|
676
|
+
wallet: walletClient
|
|
677
|
+
})
|
|
678
|
+
|
|
679
|
+
// First batch of 4 yields only 3 valid quotes. The second iteration must
|
|
680
|
+
// request just 1 more provider (h5), not fire to hosts 5-8.
|
|
681
|
+
const { quoteCalls } = wireMocks(uploader, {
|
|
682
|
+
'https://h1.example': 100,
|
|
683
|
+
'https://h2.example': 200,
|
|
684
|
+
'https://h3.example': 'error',
|
|
685
|
+
'https://h4.example': 300,
|
|
686
|
+
'https://h5.example': 400,
|
|
687
|
+
'https://h6.example': 500,
|
|
688
|
+
'https://h7.example': 600,
|
|
689
|
+
'https://h8.example': 700
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
await uploader.publishFile({
|
|
693
|
+
file: { data: stringToUtf8Array('exact-remaining batching'), type: 'text/plain' },
|
|
694
|
+
retentionPeriod: 60
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
// First batch queried h1-h4 (4 hosts), second batch queried only h5
|
|
698
|
+
// (1 host, the exact remainder). Hosts h6-h8 are never contacted.
|
|
699
|
+
expect(quoteCalls.sort()).toEqual([
|
|
700
|
+
'https://h1.example/quote',
|
|
701
|
+
'https://h2.example/quote',
|
|
702
|
+
'https://h3.example/quote',
|
|
703
|
+
'https://h4.example/quote',
|
|
704
|
+
'https://h5.example/quote'
|
|
705
|
+
])
|
|
706
|
+
})
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
describe('StorageUploader — multi-host findFile / listUploads / renewFile', () => {
|
|
710
|
+
let walletClient: WalletClient
|
|
711
|
+
|
|
712
|
+
beforeEach(() => {
|
|
713
|
+
walletClient = new WalletClient('json-api', 'non-admin.com')
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
afterEach(() => {
|
|
717
|
+
jest.restoreAllMocks()
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Spies on the authFetch instance with a URL-routed handler. Each host
|
|
722
|
+
* gets a handler keyed by `${host}/find`, `${host}/list`, `${host}/renew`.
|
|
723
|
+
*/
|
|
724
|
+
function wireAuthFetch (
|
|
725
|
+
uploader: StorageUploader,
|
|
726
|
+
handler: (url: string) => Promise<Response>
|
|
727
|
+
): jest.SpyInstance {
|
|
728
|
+
return jest
|
|
729
|
+
.spyOn((uploader as any).authFetch, 'fetch')
|
|
730
|
+
.mockImplementation(async (input: RequestInfo | URL) => await handler(extractFetchURL(input)))
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
it('findFile fans out to every configured host and picks the longest-expiry result', async () => {
|
|
734
|
+
const uploader = new StorageUploader({
|
|
735
|
+
storageURLs: ['https://a.example', 'https://b.example', 'https://c.example'],
|
|
736
|
+
wallet: walletClient
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
const calls: string[] = []
|
|
740
|
+
wireAuthFetch(uploader, async url => {
|
|
741
|
+
calls.push(url)
|
|
742
|
+
if (url.startsWith('https://a.example/find')) {
|
|
743
|
+
return jsonResponse({
|
|
744
|
+
status: 'success',
|
|
745
|
+
data: { name: 'a-name', size: '10', mimeType: 'text/plain', expiryTime: 100 }
|
|
746
|
+
})
|
|
747
|
+
}
|
|
748
|
+
if (url.startsWith('https://b.example/find')) {
|
|
749
|
+
// Host b has a longer expiry — it should win.
|
|
750
|
+
return jsonResponse({
|
|
751
|
+
status: 'success',
|
|
752
|
+
data: { name: 'b-name', size: '10', mimeType: 'text/plain', expiryTime: 500 }
|
|
753
|
+
})
|
|
754
|
+
}
|
|
755
|
+
// Host c does not have the file.
|
|
756
|
+
return jsonResponse({
|
|
757
|
+
status: 'error',
|
|
758
|
+
code: 'ERR_NOT_FOUND',
|
|
759
|
+
description: 'nope',
|
|
760
|
+
data: { name: '', size: '', mimeType: '', expiryTime: 0 }
|
|
761
|
+
})
|
|
762
|
+
})
|
|
763
|
+
|
|
764
|
+
const result = await uploader.findFile('uhrp://x')
|
|
765
|
+
|
|
766
|
+
expect(calls.sort()).toEqual([
|
|
767
|
+
'https://a.example/find?uhrpUrl=uhrp%3A%2F%2Fx',
|
|
768
|
+
'https://b.example/find?uhrpUrl=uhrp%3A%2F%2Fx',
|
|
769
|
+
'https://c.example/find?uhrpUrl=uhrp%3A%2F%2Fx'
|
|
770
|
+
])
|
|
771
|
+
expect(result.name).toBe('b-name')
|
|
772
|
+
expect(result.expiryTime).toBe(500)
|
|
773
|
+
expect(result.hostedBy).toEqual(['https://b.example', 'https://a.example'])
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
it('findFile scopes to options.hostedBy when provided', async () => {
|
|
777
|
+
const uploader = new StorageUploader({
|
|
778
|
+
storageURLs: ['https://a.example', 'https://b.example', 'https://c.example'],
|
|
779
|
+
wallet: walletClient
|
|
780
|
+
})
|
|
781
|
+
|
|
782
|
+
const calls: string[] = []
|
|
783
|
+
wireAuthFetch(uploader, async url => {
|
|
784
|
+
calls.push(url)
|
|
785
|
+
return jsonResponse({
|
|
786
|
+
status: 'success',
|
|
787
|
+
data: { name: 'x', size: '10', mimeType: 'text/plain', expiryTime: 200 }
|
|
788
|
+
})
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
await uploader.findFile('uhrp://x', { hostedBy: ['https://b.example'] })
|
|
792
|
+
expect(calls).toEqual(['https://b.example/find?uhrpUrl=uhrp%3A%2F%2Fx'])
|
|
793
|
+
})
|
|
794
|
+
|
|
795
|
+
it('findFile throws with an aggregated error when every host fails (multi-host)', async () => {
|
|
796
|
+
const uploader = new StorageUploader({
|
|
797
|
+
storageURLs: ['https://a.example', 'https://b.example'],
|
|
798
|
+
wallet: walletClient
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
wireAuthFetch(uploader, async () => jsonResponse({
|
|
802
|
+
status: 'error', code: 'ERR_NOT_FOUND', description: 'nope'
|
|
803
|
+
}))
|
|
804
|
+
|
|
805
|
+
await expect(uploader.findFile('uhrp://ghost'))
|
|
806
|
+
.rejects.toThrow(/no configured host reported this UHRP URL/)
|
|
807
|
+
})
|
|
808
|
+
|
|
809
|
+
it('findFile rejects hostedBy sets with no configured intersection', async () => {
|
|
810
|
+
const uploader = new StorageUploader({
|
|
811
|
+
storageURLs: ['https://a.example'],
|
|
812
|
+
wallet: walletClient
|
|
813
|
+
})
|
|
814
|
+
await expect(uploader.findFile('uhrp://x', { hostedBy: ['https://unknown.example'] }))
|
|
815
|
+
.rejects.toThrow(/did not intersect any configured provider/)
|
|
816
|
+
})
|
|
817
|
+
|
|
818
|
+
it('listUploads unions entries from every host and merges by UHRP URL', async () => {
|
|
819
|
+
const uploader = new StorageUploader({
|
|
820
|
+
storageURLs: ['https://a.example', 'https://b.example'],
|
|
821
|
+
wallet: walletClient
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
wireAuthFetch(uploader, async url => {
|
|
825
|
+
if (url.startsWith('https://a.example/list')) {
|
|
826
|
+
return jsonResponse({
|
|
827
|
+
status: 'success',
|
|
828
|
+
uploads: [
|
|
829
|
+
{ uhrpUrl: 'uhrp://one', expiryTime: 100 },
|
|
830
|
+
{ uhrpUrl: 'uhrp://shared', expiryTime: 150 }
|
|
831
|
+
]
|
|
832
|
+
})
|
|
833
|
+
}
|
|
834
|
+
return jsonResponse({
|
|
835
|
+
status: 'success',
|
|
836
|
+
uploads: [
|
|
837
|
+
{ uhrpUrl: 'uhrp://two', expiryTime: 200 },
|
|
838
|
+
{ uhrpUrl: 'uhrp://shared', expiryTime: 300 } // longer expiry on b
|
|
839
|
+
]
|
|
840
|
+
})
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
const listing = await uploader.listUploads()
|
|
844
|
+
const byUrl = Object.fromEntries(listing.map((e: any) => [e.uhrpUrl, e]))
|
|
845
|
+
|
|
846
|
+
expect(Object.keys(byUrl).sort()).toEqual(['uhrp://one', 'uhrp://shared', 'uhrp://two'])
|
|
847
|
+
expect(byUrl['uhrp://shared'].expiryTime).toBe(300) // longest wins
|
|
848
|
+
expect(byUrl['uhrp://shared'].hostedBy.sort()).toEqual(['https://a.example', 'https://b.example'])
|
|
849
|
+
expect(byUrl['uhrp://one'].hostedBy).toEqual(['https://a.example'])
|
|
850
|
+
expect(byUrl['uhrp://two'].hostedBy).toEqual(['https://b.example'])
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
it('listUploads succeeds when at least one host responds (multi-host)', async () => {
|
|
854
|
+
const uploader = new StorageUploader({
|
|
855
|
+
storageURLs: ['https://a.example', 'https://b.example'],
|
|
856
|
+
wallet: walletClient
|
|
857
|
+
})
|
|
858
|
+
|
|
859
|
+
wireAuthFetch(uploader, async url => {
|
|
860
|
+
if (url.startsWith('https://a.example/list')) {
|
|
861
|
+
return new Response(null, { status: 500 })
|
|
862
|
+
}
|
|
863
|
+
return jsonResponse({
|
|
864
|
+
status: 'success',
|
|
865
|
+
uploads: [{ uhrpUrl: 'uhrp://only', expiryTime: 100 }]
|
|
866
|
+
})
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
const listing = await uploader.listUploads()
|
|
870
|
+
expect(listing).toHaveLength(1)
|
|
871
|
+
expect(listing[0].uhrpUrl).toBe('uhrp://only')
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
it('renewFile fans out, sums amounts, and reports per-host outcomes when threshold is met', async () => {
|
|
875
|
+
const uploader = new StorageUploader({
|
|
876
|
+
storageURLs: ['https://a.example', 'https://b.example', 'https://c.example'],
|
|
877
|
+
resilienceLevel: 2,
|
|
878
|
+
wallet: walletClient
|
|
879
|
+
})
|
|
880
|
+
|
|
881
|
+
wireAuthFetch(uploader, async url => {
|
|
882
|
+
if (url === 'https://a.example/renew') {
|
|
883
|
+
return jsonResponse({
|
|
884
|
+
status: 'success',
|
|
885
|
+
prevExpiryTime: 100,
|
|
886
|
+
newExpiryTime: 1000,
|
|
887
|
+
amount: 50
|
|
888
|
+
})
|
|
889
|
+
}
|
|
890
|
+
if (url === 'https://b.example/renew') {
|
|
891
|
+
return jsonResponse({
|
|
892
|
+
status: 'success',
|
|
893
|
+
prevExpiryTime: 200,
|
|
894
|
+
newExpiryTime: 2000,
|
|
895
|
+
amount: 75
|
|
896
|
+
})
|
|
897
|
+
}
|
|
898
|
+
// Host c does not hold the file; renew errors out. Two successes still
|
|
899
|
+
// meet the resilience threshold of 2, so the overall call succeeds.
|
|
900
|
+
return jsonResponse({
|
|
901
|
+
status: 'error',
|
|
902
|
+
code: 'ERR_OLD_ADVERTISEMENT_NOT_FOUND',
|
|
903
|
+
description: 'not on this host'
|
|
904
|
+
})
|
|
905
|
+
})
|
|
906
|
+
|
|
907
|
+
const result = await uploader.renewFile('uhrp://x', 30)
|
|
908
|
+
|
|
909
|
+
expect(result.status).toBe('success')
|
|
910
|
+
// b has the longest newExpiryTime, so it drives the top-level fields.
|
|
911
|
+
expect(result.newExpiryTime).toBe(2000)
|
|
912
|
+
expect(result.prevExpiryTime).toBe(200)
|
|
913
|
+
// Aggregate total is the sum of paid hosts only.
|
|
914
|
+
expect(result.amount).toBe(125)
|
|
915
|
+
expect(result.results).toHaveLength(3)
|
|
916
|
+
const byHost = Object.fromEntries((result.results ?? []).map(r => [r.host, r]))
|
|
917
|
+
expect(byHost['https://a.example'].status).toBe('success')
|
|
918
|
+
expect(byHost['https://b.example'].status).toBe('success')
|
|
919
|
+
expect(byHost['https://c.example'].status).toBe('error')
|
|
920
|
+
expect(byHost['https://c.example'].error).toMatch(/ERR_OLD_ADVERTISEMENT_NOT_FOUND/)
|
|
921
|
+
})
|
|
922
|
+
|
|
923
|
+
it('renewFile throws RenewResiliencyError when the resilience threshold is not met', async () => {
|
|
924
|
+
const uploader = new StorageUploader({
|
|
925
|
+
storageURLs: ['https://a.example', 'https://b.example', 'https://c.example'],
|
|
926
|
+
resilienceLevel: 3,
|
|
927
|
+
wallet: walletClient
|
|
928
|
+
})
|
|
929
|
+
|
|
930
|
+
wireAuthFetch(uploader, async url => {
|
|
931
|
+
if (url === 'https://a.example/renew') {
|
|
932
|
+
return jsonResponse({
|
|
933
|
+
status: 'success',
|
|
934
|
+
prevExpiryTime: 100,
|
|
935
|
+
newExpiryTime: 1000,
|
|
936
|
+
amount: 50
|
|
937
|
+
})
|
|
938
|
+
}
|
|
939
|
+
// b and c both fail — only 1 of 3 required hosts renewed.
|
|
940
|
+
return jsonResponse({
|
|
941
|
+
status: 'error',
|
|
942
|
+
code: 'ERR_OLD_ADVERTISEMENT_NOT_FOUND',
|
|
943
|
+
description: 'not on this host'
|
|
944
|
+
})
|
|
945
|
+
})
|
|
946
|
+
|
|
947
|
+
const promise = uploader.renewFile('uhrp://x', 30)
|
|
948
|
+
await expect(promise).rejects.toBeInstanceOf(RenewResiliencyError)
|
|
949
|
+
await expect(promise).rejects.toThrow(/only 1 of 3 required hosts renewed/)
|
|
950
|
+
|
|
951
|
+
// The error must surface per-host outcomes so the caller can see which
|
|
952
|
+
// host was billed even though the overall call failed.
|
|
953
|
+
try {
|
|
954
|
+
await uploader.renewFile('uhrp://x', 30)
|
|
955
|
+
fail('expected RenewResiliencyError')
|
|
956
|
+
} catch (e) {
|
|
957
|
+
const err = e as RenewResiliencyError
|
|
958
|
+
expect(err.requiredSuccesses).toBe(3)
|
|
959
|
+
expect(err.successCount).toBe(1)
|
|
960
|
+
const byHost = Object.fromEntries(err.results.map(r => [r.host, r]))
|
|
961
|
+
expect(byHost['https://a.example'].status).toBe('success')
|
|
962
|
+
expect(byHost['https://a.example'].amount).toBe(50)
|
|
963
|
+
expect(byHost['https://b.example'].status).toBe('error')
|
|
964
|
+
expect(byHost['https://c.example'].status).toBe('error')
|
|
965
|
+
}
|
|
966
|
+
})
|
|
967
|
+
|
|
968
|
+
it('renewFile threshold is clamped to targets.length when hostedBy scopes smaller than resilienceLevel', async () => {
|
|
969
|
+
const uploader = new StorageUploader({
|
|
970
|
+
storageURLs: ['https://a.example', 'https://b.example', 'https://c.example'],
|
|
971
|
+
resilienceLevel: 3,
|
|
972
|
+
wallet: walletClient
|
|
973
|
+
})
|
|
974
|
+
|
|
975
|
+
// Scope to 2 hosts; threshold clamps to 2. Both succeed → overall success.
|
|
976
|
+
wireAuthFetch(uploader, async () => jsonResponse({
|
|
977
|
+
status: 'success',
|
|
978
|
+
prevExpiryTime: 0,
|
|
979
|
+
newExpiryTime: 500,
|
|
980
|
+
amount: 10
|
|
981
|
+
}))
|
|
982
|
+
|
|
983
|
+
const result = await uploader.renewFile('uhrp://x', 30, {
|
|
984
|
+
hostedBy: ['https://a.example', 'https://b.example']
|
|
985
|
+
})
|
|
986
|
+
expect(result.status).toBe('success')
|
|
987
|
+
expect(result.amount).toBe(20)
|
|
988
|
+
})
|
|
989
|
+
|
|
990
|
+
it('renewFile throws when every host fails (multi-host)', async () => {
|
|
991
|
+
const uploader = new StorageUploader({
|
|
992
|
+
storageURLs: ['https://a.example', 'https://b.example'],
|
|
993
|
+
resilienceLevel: 2,
|
|
994
|
+
wallet: walletClient
|
|
995
|
+
})
|
|
996
|
+
|
|
997
|
+
wireAuthFetch(uploader, async () => jsonResponse({
|
|
998
|
+
status: 'error',
|
|
999
|
+
code: 'ERR_OLD_ADVERTISEMENT_NOT_FOUND',
|
|
1000
|
+
description: 'gone'
|
|
1001
|
+
}))
|
|
1002
|
+
|
|
1003
|
+
const promise = uploader.renewFile('uhrp://ghost', 30)
|
|
1004
|
+
await expect(promise).rejects.toBeInstanceOf(RenewResiliencyError)
|
|
1005
|
+
await expect(promise).rejects.toThrow(/only 0 of 2 required hosts renewed/)
|
|
1006
|
+
})
|
|
1007
|
+
|
|
1008
|
+
it('renewFile honors options.hostedBy and only renews the specified replicas', async () => {
|
|
1009
|
+
const uploader = new StorageUploader({
|
|
1010
|
+
storageURLs: ['https://a.example', 'https://b.example', 'https://c.example'],
|
|
1011
|
+
wallet: walletClient
|
|
1012
|
+
})
|
|
1013
|
+
|
|
1014
|
+
const calls: string[] = []
|
|
1015
|
+
wireAuthFetch(uploader, async url => {
|
|
1016
|
+
calls.push(url)
|
|
1017
|
+
return jsonResponse({
|
|
1018
|
+
status: 'success',
|
|
1019
|
+
prevExpiryTime: 0,
|
|
1020
|
+
newExpiryTime: 100,
|
|
1021
|
+
amount: 10
|
|
1022
|
+
})
|
|
1023
|
+
})
|
|
1024
|
+
|
|
1025
|
+
const result = await uploader.renewFile('uhrp://x', 30, {
|
|
1026
|
+
hostedBy: ['https://a.example', 'https://c.example']
|
|
1027
|
+
})
|
|
1028
|
+
|
|
1029
|
+
expect(calls.sort()).toEqual([
|
|
1030
|
+
'https://a.example/renew',
|
|
1031
|
+
'https://c.example/renew'
|
|
1032
|
+
])
|
|
1033
|
+
expect(result.amount).toBe(20)
|
|
1034
|
+
expect(result.results).toHaveLength(2)
|
|
1035
|
+
})
|
|
1036
|
+
})
|