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