@bsv/sdk 2.0.12 → 2.0.13

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 (77) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/auth/clients/__tests__/AuthFetch.additional.test.js +827 -0
  3. package/dist/cjs/src/auth/clients/__tests__/AuthFetch.additional.test.js.map +1 -0
  4. package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js +654 -0
  5. package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js.map +1 -0
  6. package/dist/cjs/src/transaction/MerklePath.js +132 -0
  7. package/dist/cjs/src/transaction/MerklePath.js.map +1 -1
  8. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  9. package/dist/esm/src/auth/clients/__tests__/AuthFetch.additional.test.js +825 -0
  10. package/dist/esm/src/auth/clients/__tests__/AuthFetch.additional.test.js.map +1 -0
  11. package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js +619 -0
  12. package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js.map +1 -0
  13. package/dist/esm/src/transaction/MerklePath.js +132 -0
  14. package/dist/esm/src/transaction/MerklePath.js.map +1 -1
  15. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  16. package/dist/types/src/auth/clients/__tests__/AuthFetch.additional.test.d.ts +21 -0
  17. package/dist/types/src/auth/clients/__tests__/AuthFetch.additional.test.d.ts.map +1 -0
  18. package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.d.ts +2 -0
  19. package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.d.ts.map +1 -0
  20. package/dist/types/src/transaction/MerklePath.d.ts +27 -0
  21. package/dist/types/src/transaction/MerklePath.d.ts.map +1 -1
  22. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  23. package/dist/umd/bundle.js +1 -1
  24. package/dist/umd/bundle.js.map +1 -1
  25. package/docs/reference/storage.md +1 -1
  26. package/docs/reference/transaction.md +40 -0
  27. package/package.json +1 -1
  28. package/src/auth/clients/__tests__/AuthFetch.additional.test.ts +1131 -0
  29. package/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts +770 -0
  30. package/src/compat/__tests/Mnemonic.additional.test.ts +64 -0
  31. package/src/identity/__tests/IdentityClient.additional.test.ts +767 -0
  32. package/src/kvstore/__tests/LocalKVStore.additional.test.ts +611 -0
  33. package/src/kvstore/__tests/kvStoreInterpreter.test.ts +327 -0
  34. package/src/overlay-tools/__tests/HostReputationTracker.additional.test.ts +561 -0
  35. package/src/overlay-tools/__tests/LookupResolver.additional.test.ts +612 -0
  36. package/src/overlay-tools/__tests/withDoubleSpendRetry.test.ts +278 -0
  37. package/src/primitives/__tests/BigNumber.additional.test.ts +79 -0
  38. package/src/primitives/__tests/Curve.additional.test.ts +208 -0
  39. package/src/primitives/__tests/ECDSA.additional.test.ts +122 -0
  40. package/src/primitives/__tests/Hash.additional.test.ts +59 -0
  41. package/src/primitives/__tests/JacobianPoint.test.ts +308 -0
  42. package/src/primitives/__tests/Point.additional.test.ts +503 -0
  43. package/src/primitives/__tests/PublicKey.additional.test.ts +383 -0
  44. package/src/primitives/__tests/Random.additional.test.ts +262 -0
  45. package/src/primitives/__tests/Signature.test.ts +333 -0
  46. package/src/primitives/__tests/TransactionSignature.additional.test.ts +241 -0
  47. package/src/registry/__tests/RegistryClient.additional.test.ts +750 -0
  48. package/src/remittance/__tests/BasicBRC29.additional.test.ts +657 -0
  49. package/src/remittance/__tests/RemittanceManager.additional.test.ts +1272 -0
  50. package/src/script/__tests/LockingUnlockingScript.test.ts +79 -0
  51. package/src/script/__tests/Script.additional.test.ts +100 -0
  52. package/src/script/__tests/ScriptEvaluationError.test.ts +98 -0
  53. package/src/script/__tests/Spend.additional.test.ts +837 -0
  54. package/src/script/templates/__tests/RPuzzle.test.ts +134 -0
  55. package/src/transaction/MerklePath.ts +155 -0
  56. package/src/transaction/__tests/BeefParty.additional.test.ts +22 -0
  57. package/src/transaction/__tests/Broadcaster.test.ts +159 -0
  58. package/src/transaction/__tests/MerklePath.bench.test.ts +105 -0
  59. package/src/transaction/__tests/MerklePath.test.ts +80 -0
  60. package/src/transaction/__tests/Transaction.additional.test.ts +225 -0
  61. package/src/transaction/broadcasters/__tests/ARC.additional.test.ts +585 -0
  62. package/src/transaction/broadcasters/__tests/Teranode.test.ts +349 -0
  63. package/src/transaction/chaintrackers/__tests/BlockHeadersService.test.ts +253 -0
  64. package/src/transaction/chaintrackers/__tests/DefaultChainTracker.test.ts +44 -0
  65. package/src/transaction/chaintrackers/__tests/WhatsOnChain.additional.test.ts +193 -0
  66. package/src/transaction/fee-models/__tests/SatoshisPerKilobyte.test.ts +262 -0
  67. package/src/transaction/http/__tests/BinaryFetchClient.test.ts +212 -0
  68. package/src/transaction/http/__tests/DefaultHttpClient.additional.test.ts +192 -0
  69. package/src/transaction/http/__tests/DefaultHttpClient.test.ts +71 -0
  70. package/src/wallet/__tests/ProtoWallet.additional.test.ts +134 -0
  71. package/src/wallet/__tests/WERR.test.ts +212 -0
  72. package/src/wallet/__tests/WalletClient.additional.test.ts +699 -0
  73. package/src/wallet/__tests/WalletClient.substrate.test.ts +759 -0
  74. package/src/wallet/__tests/WalletError.test.ts +290 -0
  75. package/src/wallet/__tests/validationHelpers.test.ts +1218 -0
  76. package/src/wallet/substrates/__tests/HTTPWalletJSON.test.ts +496 -0
  77. package/src/wallet/substrates/__tests/HTTPWalletWire.test.ts +273 -0
@@ -0,0 +1,770 @@
1
+ import { jest } from '@jest/globals'
2
+ import { SimplifiedFetchTransport } from '../SimplifiedFetchTransport.js'
3
+ import * as Utils from '../../../primitives/utils.js'
4
+ import { AuthMessage } from '../../types.js'
5
+
6
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
7
+
8
+ /** Build a serialized general message payload. */
9
+ function buildGeneralPayload ({
10
+ path = '/api/resource',
11
+ method = 'GET',
12
+ search = '',
13
+ headers = {} as Record<string, string>,
14
+ body = null as number[] | null
15
+ } = {}): number[] {
16
+ const writer = new Utils.Writer()
17
+
18
+ // requestId: 32 bytes
19
+ writer.write(new Array(32).fill(0xab))
20
+
21
+ // method
22
+ if (method.length > 0) {
23
+ const methodBytes = Utils.toArray(method, 'utf8')
24
+ writer.writeVarIntNum(methodBytes.length)
25
+ writer.write(methodBytes)
26
+ } else {
27
+ writer.writeVarIntNum(0)
28
+ }
29
+
30
+ // path
31
+ if (path.length > 0) {
32
+ const pathBytes = Utils.toArray(path, 'utf8')
33
+ writer.writeVarIntNum(pathBytes.length)
34
+ writer.write(pathBytes)
35
+ } else {
36
+ writer.writeVarIntNum(0)
37
+ }
38
+
39
+ // search
40
+ if (search.length > 0) {
41
+ const searchBytes = Utils.toArray(search, 'utf8')
42
+ writer.writeVarIntNum(searchBytes.length)
43
+ writer.write(searchBytes)
44
+ } else {
45
+ writer.writeVarIntNum(0)
46
+ }
47
+
48
+ // headers
49
+ const headerEntries = Object.entries(headers)
50
+ writer.writeVarIntNum(headerEntries.length)
51
+ for (const [key, value] of headerEntries) {
52
+ const keyBytes = Utils.toArray(key, 'utf8')
53
+ writer.writeVarIntNum(keyBytes.length)
54
+ writer.write(keyBytes)
55
+ const valueBytes = Utils.toArray(value, 'utf8')
56
+ writer.writeVarIntNum(valueBytes.length)
57
+ writer.write(valueBytes)
58
+ }
59
+
60
+ // body
61
+ if (body != null && body.length > 0) {
62
+ writer.writeVarIntNum(body.length)
63
+ writer.write(body)
64
+ } else {
65
+ writer.writeVarIntNum(0)
66
+ }
67
+
68
+ return writer.toArray()
69
+ }
70
+
71
+ function makeGeneralMessage (overrides: Partial<AuthMessage> = {}): AuthMessage {
72
+ return {
73
+ version: '0.1',
74
+ messageType: 'general',
75
+ identityKey: 'client-key',
76
+ nonce: 'cnonce',
77
+ yourNonce: 'snonce',
78
+ payload: buildGeneralPayload(),
79
+ signature: new Array(64).fill(0),
80
+ ...overrides
81
+ }
82
+ }
83
+
84
+ function makeAuthMessage (messageType: AuthMessage['messageType'], overrides: Partial<AuthMessage> = {}): AuthMessage {
85
+ return {
86
+ version: '0.1',
87
+ messageType,
88
+ identityKey: 'client-key',
89
+ nonce: 'cnonce',
90
+ yourNonce: 'snonce',
91
+ payload: [],
92
+ signature: new Array(64).fill(0),
93
+ ...overrides
94
+ }
95
+ }
96
+
97
+ /** Build a minimal valid general response (all required BSV auth headers). */
98
+ function makeValidGeneralResponse (body = '', extraHeaders: Record<string, string> = {}): Response {
99
+ return new Response(body, {
100
+ status: 200,
101
+ headers: {
102
+ 'x-bsv-auth-version': '0.1',
103
+ 'x-bsv-auth-identity-key': 'server-key',
104
+ 'x-bsv-auth-signature': 'aabbcc',
105
+ 'x-bsv-auth-message-type': 'general',
106
+ ...extraHeaders
107
+ }
108
+ })
109
+ }
110
+
111
+ afterEach(() => {
112
+ jest.restoreAllMocks()
113
+ })
114
+
115
+ // ─── Constructor ─────────────────────────────────────────────────────────────
116
+
117
+ describe('SimplifiedFetchTransport constructor', () => {
118
+ test('throws when fetchClient is not a function', () => {
119
+ expect(() => new SimplifiedFetchTransport('https://example.com', 'not-a-function' as any))
120
+ .toThrow('SimplifiedFetchTransport requires a fetch implementation.')
121
+ })
122
+
123
+ test('throws when fetchClient is null', () => {
124
+ expect(() => new SimplifiedFetchTransport('https://example.com', null as any))
125
+ .toThrow('SimplifiedFetchTransport requires a fetch implementation.')
126
+ })
127
+
128
+ test('stores baseUrl and fetchClient', () => {
129
+ const mockFetch = jest.fn() as any
130
+ const transport = new SimplifiedFetchTransport('https://my.server.com', mockFetch)
131
+ expect(transport.baseUrl).toBe('https://my.server.com')
132
+ expect(transport.fetchClient).toBe(mockFetch)
133
+ })
134
+ })
135
+
136
+ // ─── send without onData registered ──────────────────────────────────────────
137
+
138
+ describe('SimplifiedFetchTransport send without listener', () => {
139
+ test('throws "Listen before you start speaking" when no onData registered', async () => {
140
+ const mockFetch = jest.fn() as any
141
+ const transport = new SimplifiedFetchTransport('https://example.com', mockFetch)
142
+ // Never call onData
143
+
144
+ await expect(transport.send(makeGeneralMessage())).rejects.toThrow(
145
+ 'Listen before you start speaking'
146
+ )
147
+ expect(mockFetch).not.toHaveBeenCalled()
148
+ })
149
+ })
150
+
151
+ // ─── send: non-initialRequest auth message paths ─────────────────────────────
152
+
153
+ describe('SimplifiedFetchTransport send — non-general auth message', () => {
154
+ test('non-initialRequest: resolves before response arrives and still calls onDataCallback', async () => {
155
+ let resolveResponse: (r: Response) => void
156
+ const responsePromise = new Promise<Response>((res) => { resolveResponse = res })
157
+
158
+ const mockFetch = jest.fn<() => any>().mockReturnValue(responsePromise) as any
159
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
160
+
161
+ const received: AuthMessage[] = []
162
+ await transport.onData(async (msg) => { received.push(msg) })
163
+
164
+ const sendPromise = transport.send(makeAuthMessage('initialResponse'))
165
+ // resolve before the fetch response arrives to confirm promise doesn't hang
166
+ resolveResponse!(new Response(JSON.stringify({ version: '0.1', messageType: 'initialResponse', identityKey: 'k', payload: [], signature: [] }), {
167
+ status: 200,
168
+ headers: { 'Content-Type': 'application/json' }
169
+ }))
170
+
171
+ await sendPromise
172
+ // Flush microtask queue so the background response processing completes
173
+ await new Promise<void>(resolve => setTimeout(resolve, 0))
174
+ // onDataCallback should have been invoked with the response message
175
+ expect(received).toHaveLength(1)
176
+ const firstReceived = received[0]
177
+ expect(firstReceived).toBeDefined()
178
+ expect(firstReceived?.messageType).toBe('initialResponse')
179
+ })
180
+
181
+ test('initialRequest: resolves after the response is processed', async () => {
182
+ const responseBody = JSON.stringify({ version: '0.1', messageType: 'initialRequest', identityKey: 'server-key', payload: [], signature: [] })
183
+ const mockFetch = jest.fn<() => any>().mockResolvedValue(
184
+ new Response(responseBody, { status: 200, headers: { 'Content-Type': 'application/json' } })
185
+ ) as any
186
+
187
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
188
+ const received: AuthMessage[] = []
189
+ await transport.onData(async (msg) => { received.push(msg) })
190
+
191
+ await transport.send(makeAuthMessage('initialRequest'))
192
+
193
+ expect(mockFetch).toHaveBeenCalledWith(
194
+ 'https://api.example.com/.well-known/auth',
195
+ expect.objectContaining({ method: 'POST' })
196
+ )
197
+ expect(received).toHaveLength(1)
198
+ })
199
+
200
+ test('non-ok response on auth endpoint throws unauthenticated error', async () => {
201
+ const mockFetch = jest.fn<() => any>().mockResolvedValue(
202
+ new Response('Unauthorized', { status: 401, statusText: 'Unauthorized' })
203
+ ) as any
204
+
205
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
206
+ await transport.onData(async () => {})
207
+
208
+ await expect(transport.send(makeAuthMessage('initialRequest'))).rejects.toThrow(
209
+ 'Received HTTP 401 Unauthorized from https://api.example.com/.well-known/auth without valid BSV authentication'
210
+ )
211
+ })
212
+
213
+ test('network failure on auth endpoint wraps error with context', async () => {
214
+ const mockFetch = jest.fn<() => any>().mockRejectedValue(new Error('DNS lookup failed')) as any
215
+
216
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
217
+ await transport.onData(async () => {})
218
+
219
+ await expect(transport.send(makeAuthMessage('initialRequest'))).rejects.toThrow(
220
+ 'Network error while sending authenticated request to https://api.example.com/.well-known/auth: DNS lookup failed'
221
+ )
222
+ })
223
+
224
+ test('non-Error network failure still wraps as Error string', async () => {
225
+ const mockFetch = jest.fn<() => any>().mockRejectedValue('plain string error') as any
226
+
227
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
228
+ await transport.onData(async () => {})
229
+
230
+ await expect(transport.send(makeAuthMessage('initialRequest'))).rejects.toThrow(
231
+ 'Network error while sending authenticated request to https://api.example.com/.well-known/auth: plain string error'
232
+ )
233
+ })
234
+ })
235
+
236
+ // ─── send: general message — body Content-Type handling ──────────────────────
237
+
238
+ describe('SimplifiedFetchTransport send — general message body handling', () => {
239
+ async function sendWithBody (
240
+ body: number[],
241
+ contentType: string
242
+ ): Promise<void> {
243
+ const payload = buildGeneralPayload({
244
+ method: 'POST',
245
+ path: '/data',
246
+ headers: { 'content-type': contentType },
247
+ body
248
+ })
249
+
250
+ const mockFetch = jest.fn<() => any>().mockResolvedValue(makeValidGeneralResponse()) as any
251
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
252
+ await transport.onData(async () => {})
253
+
254
+ await transport.send(makeGeneralMessage({ payload }))
255
+ return mockFetch.mock.calls[0][1]?.body
256
+ }
257
+
258
+ test('application/json body is converted to UTF-8 string', async () => {
259
+ const body = Utils.toArray('{"hello":"world"}', 'utf8') as number[]
260
+ const payload = buildGeneralPayload({ method: 'POST', path: '/json', headers: { 'content-type': 'application/json' }, body })
261
+ const mockFetch = jest.fn<() => any>().mockResolvedValue(makeValidGeneralResponse()) as any
262
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
263
+ await transport.onData(async () => {})
264
+
265
+ await transport.send(makeGeneralMessage({ payload }))
266
+
267
+ const sentBody = mockFetch.mock.calls[0][1]?.body
268
+ expect(typeof sentBody).toBe('string')
269
+ expect(sentBody).toContain('hello')
270
+ })
271
+
272
+ test('application/x-www-form-urlencoded body is converted to UTF-8 string', async () => {
273
+ const body = Utils.toArray('name=Alice&age=30', 'utf8') as number[]
274
+ const payload = buildGeneralPayload({
275
+ method: 'POST',
276
+ path: '/form',
277
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
278
+ body
279
+ })
280
+ const mockFetch = jest.fn<() => any>().mockResolvedValue(makeValidGeneralResponse()) as any
281
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
282
+ await transport.onData(async () => {})
283
+
284
+ await transport.send(makeGeneralMessage({ payload }))
285
+
286
+ const sentBody = mockFetch.mock.calls[0][1]?.body
287
+ expect(typeof sentBody).toBe('string')
288
+ })
289
+
290
+ test('text/plain body is converted to UTF-8 string', async () => {
291
+ const body = Utils.toArray('hello world', 'utf8') as number[]
292
+ const payload = buildGeneralPayload({ method: 'POST', path: '/text', headers: { 'content-type': 'text/plain' }, body })
293
+ const mockFetch = jest.fn<() => any>().mockResolvedValue(makeValidGeneralResponse()) as any
294
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
295
+ await transport.onData(async () => {})
296
+
297
+ await transport.send(makeGeneralMessage({ payload }))
298
+
299
+ const sentBody = mockFetch.mock.calls[0][1]?.body
300
+ expect(typeof sentBody).toBe('string')
301
+ expect(sentBody).toBe('hello world')
302
+ })
303
+
304
+ test('binary content-type body is converted to Uint8Array', async () => {
305
+ const body = [0x89, 0x50, 0x4e, 0x47] // PNG magic bytes
306
+ const payload = buildGeneralPayload({ method: 'POST', path: '/upload', headers: { 'content-type': 'image/png' }, body })
307
+ const mockFetch = jest.fn<() => any>().mockResolvedValue(makeValidGeneralResponse()) as any
308
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
309
+ await transport.onData(async () => {})
310
+
311
+ await transport.send(makeGeneralMessage({ payload }))
312
+
313
+ const sentBody = mockFetch.mock.calls[0][1]?.body
314
+ expect(sentBody).toBeInstanceOf(Uint8Array)
315
+ })
316
+
317
+ test('throws when body is present but content-type header is missing', async () => {
318
+ // No content-type header, but body present
319
+ const body = [1, 2, 3, 4]
320
+ const payload = buildGeneralPayload({ method: 'POST', path: '/no-ct', headers: {}, body })
321
+ const mockFetch = jest.fn<() => any>().mockResolvedValue(makeValidGeneralResponse()) as any
322
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
323
+ await transport.onData(async () => {})
324
+
325
+ await expect(transport.send(makeGeneralMessage({ payload }))).rejects.toThrow(
326
+ 'Content-Type header is required for requests with a body.'
327
+ )
328
+ })
329
+ })
330
+
331
+ // ─── send: general message — response header parsing ─────────────────────────
332
+
333
+ describe('SimplifiedFetchTransport send — general message response parsing', () => {
334
+ test('invokes onDataCallback with parsed AuthMessage', async () => {
335
+ const mockFetch = jest.fn<() => any>().mockResolvedValue(makeValidGeneralResponse()) as any
336
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
337
+
338
+ const received: AuthMessage[] = []
339
+ await transport.onData(async (msg) => { received.push(msg) })
340
+
341
+ await transport.send(makeGeneralMessage())
342
+
343
+ expect(received).toHaveLength(1)
344
+ expect(received[0].version).toBe('0.1')
345
+ expect(received[0].identityKey).toBe('server-key')
346
+ expect(received[0].messageType).toBe('general')
347
+ })
348
+
349
+ test('sets messageType to certificateRequest when x-bsv-auth-message-type is certificateRequest', async () => {
350
+ const mockFetch = jest.fn<() => any>().mockResolvedValue(
351
+ makeValidGeneralResponse('', { 'x-bsv-auth-message-type': 'certificateRequest' })
352
+ ) as any
353
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
354
+
355
+ const received: AuthMessage[] = []
356
+ await transport.onData(async (msg) => { received.push(msg) })
357
+
358
+ await transport.send(makeGeneralMessage())
359
+ expect(received[0].messageType).toBe('certificateRequest')
360
+ })
361
+
362
+ test('parses requestedCertificates header into structured object', async () => {
363
+ const certSet = { certifiers: ['certKey1'], types: {} }
364
+ const mockFetch = jest.fn<() => any>().mockResolvedValue(
365
+ makeValidGeneralResponse('', { 'x-bsv-auth-requested-certificates': JSON.stringify(certSet) })
366
+ ) as any
367
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
368
+
369
+ const received: AuthMessage[] = []
370
+ await transport.onData(async (msg) => { received.push(msg) })
371
+
372
+ await transport.send(makeGeneralMessage())
373
+ expect(received[0].requestedCertificates).toEqual(certSet)
374
+ })
375
+
376
+ test('throws on malformed requestedCertificates header', async () => {
377
+ const mockFetch = jest.fn<() => any>().mockResolvedValue(
378
+ makeValidGeneralResponse('', { 'x-bsv-auth-requested-certificates': '{invalid json' })
379
+ ) as any
380
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
381
+ await transport.onData(async () => {})
382
+
383
+ await expect(transport.send(makeGeneralMessage())).rejects.toThrow(
384
+ 'Failed to parse x-bsv-auth-requested-certificates'
385
+ )
386
+ })
387
+
388
+ test('includes x-bsv-auth-request-id in payload when present in response', async () => {
389
+ const requestIdBase64 = Utils.toBase64(new Array(32).fill(0xcc))
390
+ const mockFetch = jest.fn<() => any>().mockResolvedValue(
391
+ makeValidGeneralResponse('', { 'x-bsv-auth-request-id': requestIdBase64 })
392
+ ) as any
393
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
394
+ const received: AuthMessage[] = []
395
+ await transport.onData(async (msg) => { received.push(msg) })
396
+
397
+ await transport.send(makeGeneralMessage())
398
+ expect(received[0].payload).toBeDefined()
399
+ expect(Array.isArray(received[0].payload)).toBe(true)
400
+ })
401
+
402
+ test('includes x-bsv (non-auth) and authorization headers in signed payload', async () => {
403
+ const mockFetch = jest.fn<() => any>().mockResolvedValue(new Response('', {
404
+ status: 200,
405
+ headers: {
406
+ 'x-bsv-auth-version': '0.1',
407
+ 'x-bsv-auth-identity-key': 'server-key',
408
+ 'x-bsv-auth-signature': 'deadbeef',
409
+ 'x-bsv-custom-header': 'custom-value', // should be included
410
+ 'authorization': 'Bearer token', // should be included
411
+ 'x-bsv-auth-extra': 'excluded' // should NOT be included (x-bsv-auth prefix)
412
+ }
413
+ })) as any
414
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
415
+ const received: AuthMessage[] = []
416
+ await transport.onData(async (msg) => { received.push(msg) })
417
+
418
+ await transport.send(makeGeneralMessage())
419
+ // The payload should be non-empty (headers were serialized into it)
420
+ const firstReceived = received[0]
421
+ expect(firstReceived).toBeDefined()
422
+ expect(firstReceived?.payload?.length).toBeGreaterThan(0)
423
+ })
424
+
425
+ test('network failure on general message wraps error with URL context', async () => {
426
+ const mockFetch = jest.fn<() => any>().mockRejectedValue(new Error('timeout')) as any
427
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
428
+ await transport.onData(async () => {})
429
+
430
+ await expect(transport.send(makeGeneralMessage())).rejects.toThrow(
431
+ 'Network error while sending authenticated request to https://api.example.com/api/resource: timeout'
432
+ )
433
+ })
434
+
435
+ test('non-Error network failure on general message uses String()', async () => {
436
+ const mockFetch = jest.fn<() => any>().mockRejectedValue(42) as any
437
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
438
+ await transport.onData(async () => {})
439
+
440
+ await expect(transport.send(makeGeneralMessage())).rejects.toThrow(
441
+ 'Network error while sending authenticated request to https://api.example.com/api/resource: 42'
442
+ )
443
+ })
444
+
445
+ test('appends headers from httpRequest when headers field is not an object', async () => {
446
+ // Build a payload where the deserialized httpRequest has no headers field by
447
+ // providing 0 headers in the encoded payload (the transport then sets headers to {})
448
+ const payload = buildGeneralPayload({ method: 'GET', path: '/resource', headers: {} })
449
+ const mockFetch = jest.fn<() => any>().mockResolvedValue(makeValidGeneralResponse()) as any
450
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
451
+ await transport.onData(async () => {})
452
+
453
+ await transport.send(makeGeneralMessage({ payload }))
454
+
455
+ const requestInit = mockFetch.mock.calls[0][1]
456
+ expect(requestInit.headers).toMatchObject({
457
+ 'x-bsv-auth-version': '0.1',
458
+ 'x-bsv-auth-identity-key': 'client-key'
459
+ })
460
+ })
461
+ })
462
+
463
+ // ─── deserializeRequestPayload ────────────────────────────────────────────────
464
+
465
+ describe('SimplifiedFetchTransport deserializeRequestPayload', () => {
466
+ let transport: SimplifiedFetchTransport
467
+
468
+ beforeEach(() => {
469
+ transport = new SimplifiedFetchTransport('https://example.com', jest.fn() as any)
470
+ })
471
+
472
+ test('returns GET and empty urlPostfix when method and path lengths are 0', () => {
473
+ const writer = new Utils.Writer()
474
+ writer.write(new Array(32).fill(0)) // requestId
475
+ writer.writeVarIntNum(0) // method length 0
476
+ writer.writeVarIntNum(0) // path length 0
477
+ writer.writeVarIntNum(0) // search length 0
478
+ writer.writeVarIntNum(0) // 0 headers
479
+ writer.writeVarIntNum(0) // body length 0
480
+
481
+ const result = transport.deserializeRequestPayload(writer.toArray())
482
+ expect(result.method).toBe('GET')
483
+ expect(result.urlPostfix).toBe('')
484
+ expect(result.body).toBeUndefined()
485
+ })
486
+
487
+ test('combines path and search into urlPostfix', () => {
488
+ const payload = buildGeneralPayload({ path: '/items', search: '?page=2', method: 'GET' })
489
+ const result = transport.deserializeRequestPayload(payload)
490
+ expect(result.urlPostfix).toBe('/items?page=2')
491
+ })
492
+
493
+ test('deserializes headers correctly', () => {
494
+ const payload = buildGeneralPayload({
495
+ headers: { 'x-custom': 'value1', 'accept': 'application/json' }
496
+ })
497
+ const result = transport.deserializeRequestPayload(payload)
498
+ expect(result.headers['x-custom']).toBe('value1')
499
+ expect(result.headers['accept']).toBe('application/json')
500
+ })
501
+
502
+ test('deserializes body when present', () => {
503
+ const bodyBytes = [10, 20, 30, 40]
504
+ const payload = buildGeneralPayload({
505
+ method: 'POST',
506
+ headers: { 'content-type': 'application/octet-stream' },
507
+ body: bodyBytes
508
+ })
509
+ const result = transport.deserializeRequestPayload(payload)
510
+ expect(result.body).toEqual(bodyBytes)
511
+ })
512
+
513
+ test('returns undefined body when body length is 0', () => {
514
+ const payload = buildGeneralPayload({ body: null })
515
+ const result = transport.deserializeRequestPayload(payload)
516
+ expect(result.body).toBeUndefined()
517
+ })
518
+
519
+ test('returns correct requestId as base64', () => {
520
+ const payload = buildGeneralPayload()
521
+ const result = transport.deserializeRequestPayload(payload)
522
+ // The requestId is the base64 of 32 bytes of 0xab
523
+ expect(typeof result.requestId).toBe('string')
524
+ expect(result.requestId.length).toBeGreaterThan(0)
525
+ })
526
+ })
527
+
528
+ // ─── onData callback registration ────────────────────────────────────────────
529
+
530
+ describe('SimplifiedFetchTransport onData', () => {
531
+ test('registers callback and errors from callback are swallowed', async () => {
532
+ const mockFetch = jest.fn<() => any>().mockResolvedValue(makeValidGeneralResponse()) as any
533
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
534
+
535
+ await transport.onData(async (_msg) => {
536
+ throw new Error('intentional callback error')
537
+ })
538
+
539
+ // Should not throw even though callback throws
540
+ await expect(transport.send(makeGeneralMessage())).resolves.toBeUndefined()
541
+ })
542
+ })
543
+
544
+ // ─── getBodyPreview (via error path in unauthenticated response) ─────────────
545
+
546
+ describe('SimplifiedFetchTransport body preview in error messages', () => {
547
+ test('includes text body preview in unauthenticated error', async () => {
548
+ const mockFetch: any = jest.fn()
549
+ mockFetch.mockResolvedValue(new Response('{"error":"forbidden"}', {
550
+ status: 403,
551
+ statusText: 'Forbidden',
552
+ headers: { 'Content-Type': 'application/json' }
553
+ }))
554
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
555
+ await transport.onData(async () => {})
556
+
557
+ let caught: any
558
+ try {
559
+ await transport.send(makeGeneralMessage())
560
+ } catch (e) {
561
+ caught = e
562
+ }
563
+ expect(caught).toBeDefined()
564
+ expect(caught.message).toContain('forbidden')
565
+ })
566
+
567
+ test('body preview is omitted when body is empty', async () => {
568
+ const mockFetch: any = jest.fn()
569
+ mockFetch.mockResolvedValue(new Response('', { status: 503, headers: {} }))
570
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
571
+ await transport.onData(async () => {})
572
+
573
+ let caught: any
574
+ try {
575
+ await transport.send(makeGeneralMessage())
576
+ } catch (e) {
577
+ caught = e
578
+ }
579
+ expect(caught.message).not.toContain('body preview')
580
+ })
581
+
582
+ test('binary body produces hex preview', async () => {
583
+ // Binary bytes (low printability ratio)
584
+ const binaryBytes = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0x80, 0x81, 0xff, 0xfe])
585
+ const mockFetch: any = jest.fn()
586
+ mockFetch.mockResolvedValue(new Response(binaryBytes.buffer as ArrayBuffer, {
587
+ status: 401,
588
+ headers: { 'Content-Type': 'application/octet-stream' }
589
+ }))
590
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
591
+ await transport.onData(async () => {})
592
+
593
+ let caught: any
594
+ try {
595
+ await transport.send(makeGeneralMessage())
596
+ } catch (e) {
597
+ caught = e
598
+ }
599
+ // Should contain 0x prefix from binary hex formatting
600
+ expect(caught.message).toContain('0x')
601
+ })
602
+
603
+ test('large body (>1024 bytes) is truncated in preview', async () => {
604
+ const largeBody = 'x'.repeat(2000)
605
+ const mockFetch: any = jest.fn()
606
+ mockFetch.mockResolvedValue(new Response(largeBody, {
607
+ status: 401,
608
+ headers: { 'Content-Type': 'text/plain' }
609
+ }))
610
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
611
+ await transport.onData(async () => {})
612
+
613
+ let caught: any
614
+ try {
615
+ await transport.send(makeGeneralMessage())
616
+ } catch (e) {
617
+ caught = e
618
+ }
619
+ expect(caught.message).toContain('truncated')
620
+ })
621
+
622
+ test('preview longer than 512 chars is truncated with ellipsis', async () => {
623
+ // Body that is textual and between 512 and 1024 chars (not truncated due to length, but truncated for preview)
624
+ const mediumBody = 'A'.repeat(600)
625
+ const mockFetch: any = jest.fn()
626
+ mockFetch.mockResolvedValue(new Response(mediumBody, {
627
+ status: 401,
628
+ headers: { 'Content-Type': 'text/plain' }
629
+ }))
630
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
631
+ await transport.onData(async () => {})
632
+
633
+ let caught: any
634
+ try {
635
+ await transport.send(makeGeneralMessage())
636
+ } catch (e) {
637
+ caught = e
638
+ }
639
+ expect(caught.message).toContain('…')
640
+ })
641
+
642
+ test('status description includes statusText when non-empty', async () => {
643
+ const mockFetch: any = jest.fn()
644
+ mockFetch.mockResolvedValue(new Response('error text', {
645
+ status: 422,
646
+ statusText: 'Unprocessable Entity',
647
+ headers: { 'Content-Type': 'text/plain' }
648
+ }))
649
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
650
+ await transport.onData(async () => {})
651
+
652
+ let caught: any
653
+ try {
654
+ await transport.send(makeGeneralMessage())
655
+ } catch (e) {
656
+ caught = e
657
+ }
658
+ expect(caught.message).toContain('422 Unprocessable Entity')
659
+ })
660
+
661
+ test('error details contains missingHeaders as empty array when all missing', async () => {
662
+ const mockFetch: any = jest.fn()
663
+ mockFetch.mockResolvedValue(new Response('body', { status: 200 }))
664
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
665
+ await transport.onData(async () => {})
666
+
667
+ let caught: any
668
+ try {
669
+ await transport.send(makeGeneralMessage())
670
+ } catch (e) {
671
+ caught = e
672
+ }
673
+ expect(caught.details.missingHeaders).toEqual([
674
+ 'x-bsv-auth-version',
675
+ 'x-bsv-auth-identity-key',
676
+ 'x-bsv-auth-signature'
677
+ ])
678
+ })
679
+ })
680
+
681
+ // ─── createMalformedHeaderError — non-Error cause ────────────────────────────
682
+
683
+ describe('SimplifiedFetchTransport malformed header error — non-Error cause', () => {
684
+ test('formats error when JSON.parse throws a non-Error value (string cause)', async () => {
685
+ // Spy on JSON.parse to throw a string
686
+ const originalParse = JSON.parse
687
+ jest.spyOn(JSON, 'parse').mockImplementation((text) => {
688
+ if (text === '{bad}') throw 'string error cause'
689
+ return originalParse(text)
690
+ })
691
+
692
+ const mockFetch: any = jest.fn()
693
+ mockFetch.mockResolvedValue(new Response('', {
694
+ status: 200,
695
+ headers: {
696
+ 'x-bsv-auth-version': '0.1',
697
+ 'x-bsv-auth-identity-key': 'server-key',
698
+ 'x-bsv-auth-signature': 'aabbcc',
699
+ 'x-bsv-auth-requested-certificates': '{bad}'
700
+ }
701
+ }))
702
+
703
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
704
+ await transport.onData(async () => {})
705
+
706
+ let caught: any
707
+ try {
708
+ await transport.send(makeGeneralMessage())
709
+ } catch (e) {
710
+ caught = e
711
+ }
712
+
713
+ expect(caught).toBeDefined()
714
+ expect(caught.message).toContain('Failed to parse x-bsv-auth-requested-certificates')
715
+ expect(caught.message).toContain('string error cause')
716
+ })
717
+ })
718
+
719
+ // ─── isTextualContent heuristics ─────────────────────────────────────────────
720
+
721
+ describe('SimplifiedFetchTransport — isTextualContent heuristics (via send response)', () => {
722
+ async function sendAndCatchError (body: BodyInit | null, contentType: string | null): Promise<Error> {
723
+ const headers: Record<string, string> = {}
724
+ if (contentType != null) {
725
+ headers['Content-Type'] = contentType
726
+ }
727
+
728
+ const mockFetch: any = jest.fn()
729
+ mockFetch.mockResolvedValue(new Response(body, { status: 401, headers }))
730
+ const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch)
731
+ await transport.onData(async () => {})
732
+
733
+ try {
734
+ await transport.send(makeGeneralMessage())
735
+ throw new Error('expected rejection')
736
+ } catch (e) {
737
+ return e as Error
738
+ }
739
+ }
740
+
741
+ test('application/problem+json is treated as text', async () => {
742
+ const err = await sendAndCatchError('{"detail":"bad"}', 'application/problem+json')
743
+ expect(err.message).toContain('bad')
744
+ })
745
+
746
+ test('application/xml is treated as text', async () => {
747
+ const err = await sendAndCatchError('<root>value</root>', 'application/xml')
748
+ expect(err.message).toContain('root')
749
+ })
750
+
751
+ test('charset= in content type is treated as text', async () => {
752
+ const err = await sendAndCatchError('hello chars', 'application/octet-stream; charset=utf-8')
753
+ expect(err.message).toContain('hello chars')
754
+ })
755
+
756
+ test('null content type with mostly printable bytes is treated as text', async () => {
757
+ // >80% printable ASCII
758
+ const printableBody = 'Hello World from the server! This is all ASCII text.'
759
+ const err = await sendAndCatchError(printableBody, null)
760
+ // Should be treated as text, not binary hex
761
+ expect(err.message).not.toMatch(/^.*0x[0-9a-f]+/)
762
+ })
763
+
764
+ test('null content type with mostly binary bytes treated as binary', async () => {
765
+ // Lots of control/non-printable bytes
766
+ const binaryBytes = new Uint8Array(50).fill(0x01)
767
+ const err = await sendAndCatchError(binaryBytes.buffer as ArrayBuffer, null)
768
+ expect(err.message).toContain('0x')
769
+ })
770
+ })