@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.
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/src/auth/clients/__tests__/AuthFetch.additional.test.js +827 -0
- package/dist/cjs/src/auth/clients/__tests__/AuthFetch.additional.test.js.map +1 -0
- package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js +654 -0
- package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js.map +1 -0
- package/dist/cjs/src/transaction/MerklePath.js +132 -0
- package/dist/cjs/src/transaction/MerklePath.js.map +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/auth/clients/__tests__/AuthFetch.additional.test.js +825 -0
- package/dist/esm/src/auth/clients/__tests__/AuthFetch.additional.test.js.map +1 -0
- package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js +619 -0
- package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js.map +1 -0
- package/dist/esm/src/transaction/MerklePath.js +132 -0
- package/dist/esm/src/transaction/MerklePath.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/auth/clients/__tests__/AuthFetch.additional.test.d.ts +21 -0
- package/dist/types/src/auth/clients/__tests__/AuthFetch.additional.test.d.ts.map +1 -0
- package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.d.ts +2 -0
- package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.d.ts.map +1 -0
- package/dist/types/src/transaction/MerklePath.d.ts +27 -0
- package/dist/types/src/transaction/MerklePath.d.ts.map +1 -1
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/dist/umd/bundle.js +1 -1
- package/dist/umd/bundle.js.map +1 -1
- package/docs/reference/storage.md +1 -1
- package/docs/reference/transaction.md +40 -0
- package/package.json +1 -1
- package/src/auth/clients/__tests__/AuthFetch.additional.test.ts +1131 -0
- package/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts +770 -0
- package/src/compat/__tests/Mnemonic.additional.test.ts +64 -0
- package/src/identity/__tests/IdentityClient.additional.test.ts +767 -0
- package/src/kvstore/__tests/LocalKVStore.additional.test.ts +611 -0
- package/src/kvstore/__tests/kvStoreInterpreter.test.ts +327 -0
- package/src/overlay-tools/__tests/HostReputationTracker.additional.test.ts +561 -0
- package/src/overlay-tools/__tests/LookupResolver.additional.test.ts +612 -0
- package/src/overlay-tools/__tests/withDoubleSpendRetry.test.ts +278 -0
- package/src/primitives/__tests/BigNumber.additional.test.ts +79 -0
- package/src/primitives/__tests/Curve.additional.test.ts +208 -0
- package/src/primitives/__tests/ECDSA.additional.test.ts +122 -0
- package/src/primitives/__tests/Hash.additional.test.ts +59 -0
- package/src/primitives/__tests/JacobianPoint.test.ts +308 -0
- package/src/primitives/__tests/Point.additional.test.ts +503 -0
- package/src/primitives/__tests/PublicKey.additional.test.ts +383 -0
- package/src/primitives/__tests/Random.additional.test.ts +262 -0
- package/src/primitives/__tests/Signature.test.ts +333 -0
- package/src/primitives/__tests/TransactionSignature.additional.test.ts +241 -0
- package/src/registry/__tests/RegistryClient.additional.test.ts +750 -0
- package/src/remittance/__tests/BasicBRC29.additional.test.ts +657 -0
- package/src/remittance/__tests/RemittanceManager.additional.test.ts +1272 -0
- package/src/script/__tests/LockingUnlockingScript.test.ts +79 -0
- package/src/script/__tests/Script.additional.test.ts +100 -0
- package/src/script/__tests/ScriptEvaluationError.test.ts +98 -0
- package/src/script/__tests/Spend.additional.test.ts +837 -0
- package/src/script/templates/__tests/RPuzzle.test.ts +134 -0
- package/src/transaction/MerklePath.ts +155 -0
- package/src/transaction/__tests/BeefParty.additional.test.ts +22 -0
- package/src/transaction/__tests/Broadcaster.test.ts +159 -0
- package/src/transaction/__tests/MerklePath.bench.test.ts +105 -0
- package/src/transaction/__tests/MerklePath.test.ts +80 -0
- package/src/transaction/__tests/Transaction.additional.test.ts +225 -0
- package/src/transaction/broadcasters/__tests/ARC.additional.test.ts +585 -0
- package/src/transaction/broadcasters/__tests/Teranode.test.ts +349 -0
- package/src/transaction/chaintrackers/__tests/BlockHeadersService.test.ts +253 -0
- package/src/transaction/chaintrackers/__tests/DefaultChainTracker.test.ts +44 -0
- package/src/transaction/chaintrackers/__tests/WhatsOnChain.additional.test.ts +193 -0
- package/src/transaction/fee-models/__tests/SatoshisPerKilobyte.test.ts +262 -0
- package/src/transaction/http/__tests/BinaryFetchClient.test.ts +212 -0
- package/src/transaction/http/__tests/DefaultHttpClient.additional.test.ts +192 -0
- package/src/transaction/http/__tests/DefaultHttpClient.test.ts +71 -0
- package/src/wallet/__tests/ProtoWallet.additional.test.ts +134 -0
- package/src/wallet/__tests/WERR.test.ts +212 -0
- package/src/wallet/__tests/WalletClient.additional.test.ts +699 -0
- package/src/wallet/__tests/WalletClient.substrate.test.ts +759 -0
- package/src/wallet/__tests/WalletError.test.ts +290 -0
- package/src/wallet/__tests/validationHelpers.test.ts +1218 -0
- package/src/wallet/substrates/__tests/HTTPWalletJSON.test.ts +496 -0
- package/src/wallet/substrates/__tests/HTTPWalletWire.test.ts +273 -0
|
@@ -0,0 +1,1131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AuthFetch additional tests.
|
|
3
|
+
*
|
|
4
|
+
* Covers branches not exercised by the primary AuthFetch.test.ts:
|
|
5
|
+
* - fetch(): retryCounter exhaustion, non-auth fallback path, stale-session retry
|
|
6
|
+
* - serializeRequest(): all header/body/URL variants
|
|
7
|
+
* - handleFetchAndValidate(): success, x-bsv header spoofing, non-ok response
|
|
8
|
+
* - handlePaymentAndRetry(): missing/invalid headers, incompatible context regeneration
|
|
9
|
+
* - describeRequestBodyForLogging(): all body types
|
|
10
|
+
* - getMaxPaymentAttempts(): edge cases
|
|
11
|
+
* - getPaymentRetryDelay(): values at different attempt counts
|
|
12
|
+
* - wait(): zero / positive
|
|
13
|
+
* - isPaymentContextCompatible(): match / mismatch branches
|
|
14
|
+
* - consumeReceivedCertificates(): drains the internal buffer
|
|
15
|
+
* - sendCertificateRequest(): creates new peer when none exists
|
|
16
|
+
* - logPaymentAttempt(): all three log levels
|
|
17
|
+
* - createPaymentErrorEntry(): Error vs non-Error values
|
|
18
|
+
* - buildPaymentFailureError(): shapes the error correctly
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { jest } from '@jest/globals'
|
|
22
|
+
import { AuthFetch } from '../AuthFetch.js'
|
|
23
|
+
import { Utils, PrivateKey } from '../../../primitives/index.js'
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Module mock for createNonce (matches what primary test does)
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
jest.mock('../../utils/createNonce.js', () => ({
|
|
30
|
+
createNonce: jest.fn()
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
import { createNonce } from '../../utils/createNonce.js'
|
|
34
|
+
|
|
35
|
+
const createNonceMock = createNonce as jest.MockedFunction<typeof createNonce>
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Helpers
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
function buildWallet (): any {
|
|
42
|
+
const identityKey = new PrivateKey(10).toPublicKey().toString()
|
|
43
|
+
const derivedKey = new PrivateKey(11).toPublicKey().toString()
|
|
44
|
+
return {
|
|
45
|
+
getPublicKey: jest.fn(async (opts: any) =>
|
|
46
|
+
opts?.identityKey === true ? { publicKey: identityKey } : { publicKey: derivedKey }
|
|
47
|
+
),
|
|
48
|
+
createAction: jest.fn(async () => ({
|
|
49
|
+
tx: Utils.toArray('mock-tx', 'utf8')
|
|
50
|
+
})),
|
|
51
|
+
createHmac: jest.fn(async () => ({ hmac: new Array(32).fill(0) }))
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function make402Response (overrides: Record<string, string> = {}): Response {
|
|
56
|
+
const headers: Record<string, string> = {
|
|
57
|
+
'x-bsv-payment-version': '1.0',
|
|
58
|
+
'x-bsv-payment-satoshis-required': '10',
|
|
59
|
+
'x-bsv-auth-identity-key': 'srv-key',
|
|
60
|
+
'x-bsv-payment-derivation-prefix': 'pfx',
|
|
61
|
+
...overrides
|
|
62
|
+
}
|
|
63
|
+
return new Response('', { status: 402, headers })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
jest.restoreAllMocks()
|
|
68
|
+
createNonceMock.mockReset()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// 1. fetch() – retryCounter exhaustion
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
describe('AuthFetch.fetch – retryCounter', () => {
|
|
76
|
+
it('throws when retryCounter reaches 0', async () => {
|
|
77
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
78
|
+
await expect(
|
|
79
|
+
authFetch.fetch('https://example.com', { retryCounter: 0 })
|
|
80
|
+
).rejects.toThrow('Request failed after maximum number of retries.')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('decrements retryCounter before making the request', async () => {
|
|
84
|
+
// Verify that the stale-session retry path calls fetch() again, which means
|
|
85
|
+
// retryCounter gets decremented. We intercept the recursive fetch() call using
|
|
86
|
+
// a spy so a real Peer is never constructed inside the unit-test environment.
|
|
87
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
88
|
+
|
|
89
|
+
let fetchCallCount = 0
|
|
90
|
+
|
|
91
|
+
const originalFetch = authFetch.fetch.bind(authFetch)
|
|
92
|
+
jest.spyOn(authFetch, 'fetch').mockImplementation(async (url, config) => {
|
|
93
|
+
fetchCallCount++
|
|
94
|
+
if (fetchCallCount === 1) {
|
|
95
|
+
// First call: run the real code path so the stale-session branch triggers
|
|
96
|
+
return originalFetch(url, config)
|
|
97
|
+
}
|
|
98
|
+
// Subsequent calls (recursive retry after stale-session): throw to prove
|
|
99
|
+
// the retry occurred with a decremented retryCounter.
|
|
100
|
+
throw new Error('second call')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// Inject a stub peer that throws a stale-session error on toPeer()
|
|
104
|
+
const peerStub = {
|
|
105
|
+
listenForCertificatesReceived: jest.fn(),
|
|
106
|
+
listenForCertificatesRequested: jest.fn(),
|
|
107
|
+
listenForGeneralMessages: jest.fn(() => 1),
|
|
108
|
+
stopListeningForGeneralMessages: jest.fn(),
|
|
109
|
+
toPeer: jest.fn(async () => {
|
|
110
|
+
throw new Error('Session not found for nonce xyz')
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
;(authFetch as any).peers['https://example.com'] = {
|
|
114
|
+
peer: peerStub,
|
|
115
|
+
identityKey: 'some-key',
|
|
116
|
+
supportsMutualAuth: true,
|
|
117
|
+
pendingCertificateRequests: []
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// With retryCounter: 2, the stale-session branch retries once; the spy
|
|
121
|
+
// intercepts the recursive call and throws 'second call'.
|
|
122
|
+
await expect(
|
|
123
|
+
authFetch.fetch('https://example.com/path', { retryCounter: 2 })
|
|
124
|
+
).rejects.toThrow('second call')
|
|
125
|
+
expect(fetchCallCount).toBe(2)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// 2. fetch() – supportsMutualAuth === false fallback
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
describe('AuthFetch.fetch – non-auth fallback (supportsMutualAuth=false)', () => {
|
|
134
|
+
it('falls back to handleFetchAndValidate when peer does not support mutual auth', async () => {
|
|
135
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
136
|
+
|
|
137
|
+
const handleFetchSpy = jest
|
|
138
|
+
.spyOn(authFetch as any, 'handleFetchAndValidate')
|
|
139
|
+
.mockResolvedValue(new Response('ok', { status: 200 }))
|
|
140
|
+
|
|
141
|
+
const peerStub = {
|
|
142
|
+
peer: { toPeer: jest.fn() },
|
|
143
|
+
supportsMutualAuth: false,
|
|
144
|
+
pendingCertificateRequests: []
|
|
145
|
+
}
|
|
146
|
+
;(authFetch as any).peers['https://example.com'] = peerStub
|
|
147
|
+
|
|
148
|
+
const result = await authFetch.fetch('https://example.com/resource')
|
|
149
|
+
|
|
150
|
+
expect(handleFetchSpy).toHaveBeenCalledTimes(1)
|
|
151
|
+
expect(result.status).toBe(200)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('rejects when handleFetchAndValidate throws in non-auth fallback', async () => {
|
|
155
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
156
|
+
|
|
157
|
+
jest
|
|
158
|
+
.spyOn(authFetch as any, 'handleFetchAndValidate')
|
|
159
|
+
.mockRejectedValue(new Error('fetch validation failed'))
|
|
160
|
+
|
|
161
|
+
const peerStub = {
|
|
162
|
+
peer: { toPeer: jest.fn() },
|
|
163
|
+
supportsMutualAuth: false,
|
|
164
|
+
pendingCertificateRequests: []
|
|
165
|
+
}
|
|
166
|
+
;(authFetch as any).peers['https://example.com'] = peerStub
|
|
167
|
+
|
|
168
|
+
await expect(authFetch.fetch('https://example.com/resource')).rejects.toThrow(
|
|
169
|
+
'fetch validation failed'
|
|
170
|
+
)
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// 3. handleFetchAndValidate
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
describe('AuthFetch.handleFetchAndValidate (private)', () => {
|
|
179
|
+
it('returns the response when fetch succeeds with no x-bsv headers', async () => {
|
|
180
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
181
|
+
|
|
182
|
+
const mockResponse = new Response('body', {
|
|
183
|
+
status: 200,
|
|
184
|
+
headers: { 'Content-Type': 'text/plain' }
|
|
185
|
+
})
|
|
186
|
+
jest.spyOn(global, 'fetch').mockResolvedValue(mockResponse)
|
|
187
|
+
|
|
188
|
+
const peerToUse: any = { supportsMutualAuth: undefined }
|
|
189
|
+
const response = await (authFetch as any).handleFetchAndValidate(
|
|
190
|
+
'https://example.com',
|
|
191
|
+
{ method: 'GET' },
|
|
192
|
+
peerToUse
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
expect(response.status).toBe(200)
|
|
196
|
+
expect(peerToUse.supportsMutualAuth).toBe(false)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('throws when response contains an x-bsv header (spoofing detection)', async () => {
|
|
200
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
201
|
+
|
|
202
|
+
// The source iterates response.headers.forEach((value, name) => ...)
|
|
203
|
+
// and checks if the VALUE starts with 'x-bsv'. To trigger spoofing
|
|
204
|
+
// detection we need a header whose value starts with 'x-bsv'.
|
|
205
|
+
const mockResponse = new Response('', {
|
|
206
|
+
status: 200,
|
|
207
|
+
headers: { 'x-custom-header': 'x-bsv-auth-identity-key' }
|
|
208
|
+
})
|
|
209
|
+
jest.spyOn(global, 'fetch').mockResolvedValue(mockResponse)
|
|
210
|
+
|
|
211
|
+
await expect(
|
|
212
|
+
(authFetch as any).handleFetchAndValidate(
|
|
213
|
+
'https://example.com',
|
|
214
|
+
{},
|
|
215
|
+
{ supportsMutualAuth: undefined }
|
|
216
|
+
)
|
|
217
|
+
).rejects.toThrow('The server is trying to claim it has been authenticated')
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('throws when response is not ok', async () => {
|
|
221
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
222
|
+
|
|
223
|
+
const mockResponse = new Response('Not Found', { status: 404 })
|
|
224
|
+
jest.spyOn(global, 'fetch').mockResolvedValue(mockResponse)
|
|
225
|
+
|
|
226
|
+
await expect(
|
|
227
|
+
(authFetch as any).handleFetchAndValidate(
|
|
228
|
+
'https://example.com',
|
|
229
|
+
{},
|
|
230
|
+
{ supportsMutualAuth: undefined }
|
|
231
|
+
)
|
|
232
|
+
).rejects.toThrow('Request failed with status: 404')
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// 4. handlePaymentAndRetry – missing/invalid headers
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
describe('AuthFetch.handlePaymentAndRetry – header validation', () => {
|
|
241
|
+
it('throws when x-bsv-payment-version header is missing', async () => {
|
|
242
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
243
|
+
const response = new Response('', { status: 402 }) // no payment headers
|
|
244
|
+
|
|
245
|
+
await expect(
|
|
246
|
+
(authFetch as any).handlePaymentAndRetry('https://example.com', {}, response)
|
|
247
|
+
).rejects.toThrow('Unsupported x-bsv-payment-version response header')
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('throws when x-bsv-payment-version header has wrong value', async () => {
|
|
251
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
252
|
+
const response = make402Response({ 'x-bsv-payment-version': '2.0' })
|
|
253
|
+
|
|
254
|
+
await expect(
|
|
255
|
+
(authFetch as any).handlePaymentAndRetry('https://example.com', {}, response)
|
|
256
|
+
).rejects.toThrow('Unsupported x-bsv-payment-version response header')
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('throws when x-bsv-payment-satoshis-required header is missing', async () => {
|
|
260
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
261
|
+
const response = make402Response({ 'x-bsv-payment-satoshis-required': '' })
|
|
262
|
+
// Force re-check: create a response without the satoshis header entirely
|
|
263
|
+
const headersRaw: Record<string, string> = {
|
|
264
|
+
'x-bsv-payment-version': '1.0',
|
|
265
|
+
'x-bsv-auth-identity-key': 'srv-key',
|
|
266
|
+
'x-bsv-payment-derivation-prefix': 'pfx'
|
|
267
|
+
// satoshis-required intentionally omitted
|
|
268
|
+
}
|
|
269
|
+
const respNoSatoshis = new Response('', { status: 402, headers: headersRaw })
|
|
270
|
+
|
|
271
|
+
await expect(
|
|
272
|
+
(authFetch as any).handlePaymentAndRetry('https://example.com', {}, respNoSatoshis)
|
|
273
|
+
).rejects.toThrow('Missing x-bsv-payment-satoshis-required response header')
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('throws when satoshis value is NaN', async () => {
|
|
277
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
278
|
+
const response = make402Response({ 'x-bsv-payment-satoshis-required': 'not-a-number' })
|
|
279
|
+
|
|
280
|
+
await expect(
|
|
281
|
+
(authFetch as any).handlePaymentAndRetry('https://example.com', {}, response)
|
|
282
|
+
).rejects.toThrow('Invalid x-bsv-payment-satoshis-required response header value')
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('throws when satoshis value is zero', async () => {
|
|
286
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
287
|
+
const response = make402Response({ 'x-bsv-payment-satoshis-required': '0' })
|
|
288
|
+
|
|
289
|
+
await expect(
|
|
290
|
+
(authFetch as any).handlePaymentAndRetry('https://example.com', {}, response)
|
|
291
|
+
).rejects.toThrow('Invalid x-bsv-payment-satoshis-required response header value')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('throws when x-bsv-auth-identity-key header is missing', async () => {
|
|
295
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
296
|
+
const headersRaw: Record<string, string> = {
|
|
297
|
+
'x-bsv-payment-version': '1.0',
|
|
298
|
+
'x-bsv-payment-satoshis-required': '10',
|
|
299
|
+
'x-bsv-payment-derivation-prefix': 'pfx'
|
|
300
|
+
// identity-key omitted
|
|
301
|
+
}
|
|
302
|
+
const response = new Response('', { status: 402, headers: headersRaw })
|
|
303
|
+
|
|
304
|
+
await expect(
|
|
305
|
+
(authFetch as any).handlePaymentAndRetry('https://example.com', {}, response)
|
|
306
|
+
).rejects.toThrow('Missing x-bsv-auth-identity-key response header')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('throws when x-bsv-payment-derivation-prefix header is missing', async () => {
|
|
310
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
311
|
+
const headersRaw: Record<string, string> = {
|
|
312
|
+
'x-bsv-payment-version': '1.0',
|
|
313
|
+
'x-bsv-payment-satoshis-required': '10',
|
|
314
|
+
'x-bsv-auth-identity-key': 'srv-key'
|
|
315
|
+
// derivation-prefix omitted
|
|
316
|
+
}
|
|
317
|
+
const response = new Response('', { status: 402, headers: headersRaw })
|
|
318
|
+
|
|
319
|
+
await expect(
|
|
320
|
+
(authFetch as any).handlePaymentAndRetry('https://example.com', {}, response)
|
|
321
|
+
).rejects.toThrow('Missing x-bsv-payment-derivation-prefix response header')
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('throws when derivation-prefix is an empty string', async () => {
|
|
325
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
326
|
+
const response = make402Response({ 'x-bsv-payment-derivation-prefix': '' })
|
|
327
|
+
|
|
328
|
+
await expect(
|
|
329
|
+
(authFetch as any).handlePaymentAndRetry('https://example.com', {}, response)
|
|
330
|
+
).rejects.toThrow('Missing x-bsv-payment-derivation-prefix response header')
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// 5. handlePaymentAndRetry – incompatible context triggers new context creation
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
describe('AuthFetch.handlePaymentAndRetry – context compatibility', () => {
|
|
339
|
+
it('regenerates context when server changes payment requirements', async () => {
|
|
340
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
341
|
+
jest.spyOn(authFetch as any, 'logPaymentAttempt').mockImplementation(() => {})
|
|
342
|
+
jest.spyOn(authFetch as any, 'wait').mockResolvedValue(undefined)
|
|
343
|
+
|
|
344
|
+
createNonceMock.mockResolvedValue('new-suffix')
|
|
345
|
+
|
|
346
|
+
const existingContext = {
|
|
347
|
+
satoshisRequired: 5, // server now asks for 10
|
|
348
|
+
transactionBase64: Utils.toBase64([1, 2, 3]),
|
|
349
|
+
derivationPrefix: 'pfx',
|
|
350
|
+
derivationSuffix: 'old-suffix',
|
|
351
|
+
serverIdentityKey: 'srv-key',
|
|
352
|
+
clientIdentityKey: 'client-key',
|
|
353
|
+
attempts: 0,
|
|
354
|
+
maxAttempts: 3,
|
|
355
|
+
errors: [],
|
|
356
|
+
requestSummary: {
|
|
357
|
+
url: 'https://example.com',
|
|
358
|
+
method: 'GET',
|
|
359
|
+
headers: {},
|
|
360
|
+
bodyType: 'none',
|
|
361
|
+
bodyByteLength: 0
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const fetchSpy = jest
|
|
366
|
+
.spyOn(authFetch, 'fetch')
|
|
367
|
+
.mockResolvedValue(new Response('ok', { status: 200 }))
|
|
368
|
+
|
|
369
|
+
const response = make402Response({ 'x-bsv-payment-satoshis-required': '10' }) // changed from 5
|
|
370
|
+
await (authFetch as any).handlePaymentAndRetry(
|
|
371
|
+
'https://example.com',
|
|
372
|
+
{ paymentContext: existingContext },
|
|
373
|
+
response
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
// createNonce should have been called because the context was regenerated
|
|
377
|
+
expect(createNonceMock).toHaveBeenCalled()
|
|
378
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
|
379
|
+
})
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// 6. handlePaymentAndRetry – maxAttempts exceeded before first try
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
describe('AuthFetch.handlePaymentAndRetry – maxAttempts exceeded pre-check', () => {
|
|
387
|
+
it('throws immediately when attempts >= maxAttempts', async () => {
|
|
388
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
389
|
+
jest.spyOn(authFetch as any, 'logPaymentAttempt').mockImplementation(() => {})
|
|
390
|
+
|
|
391
|
+
const exhaustedContext = {
|
|
392
|
+
satoshisRequired: 10,
|
|
393
|
+
transactionBase64: Utils.toBase64([1]),
|
|
394
|
+
derivationPrefix: 'pfx',
|
|
395
|
+
derivationSuffix: 'sfx',
|
|
396
|
+
serverIdentityKey: 'srv-key',
|
|
397
|
+
clientIdentityKey: 'client-key',
|
|
398
|
+
attempts: 3,
|
|
399
|
+
maxAttempts: 3,
|
|
400
|
+
errors: [],
|
|
401
|
+
requestSummary: {
|
|
402
|
+
url: 'https://example.com',
|
|
403
|
+
method: 'GET',
|
|
404
|
+
headers: {},
|
|
405
|
+
bodyType: 'none',
|
|
406
|
+
bodyByteLength: 0
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const response = make402Response()
|
|
411
|
+
await expect(
|
|
412
|
+
(authFetch as any).handlePaymentAndRetry(
|
|
413
|
+
'https://example.com',
|
|
414
|
+
{ paymentContext: exhaustedContext },
|
|
415
|
+
response
|
|
416
|
+
)
|
|
417
|
+
).rejects.toThrow('Paid request to https://example.com failed after 3/3 attempts')
|
|
418
|
+
})
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// 7. serializeRequest – header and body branches
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
describe('AuthFetch.serializeRequest (private)', () => {
|
|
426
|
+
it('serializes a GET request with no body or headers', async () => {
|
|
427
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
428
|
+
const nonce = new Array(32).fill(0)
|
|
429
|
+
const writer = await (authFetch as any).serializeRequest(
|
|
430
|
+
'GET',
|
|
431
|
+
{},
|
|
432
|
+
undefined,
|
|
433
|
+
new URL('https://example.com/path'),
|
|
434
|
+
nonce
|
|
435
|
+
)
|
|
436
|
+
expect(writer).toBeDefined()
|
|
437
|
+
expect(writer.toArray().length).toBeGreaterThan(32)
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('serializes a POST request with a JSON body', async () => {
|
|
441
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
442
|
+
const nonce = new Array(32).fill(1)
|
|
443
|
+
const writer = await (authFetch as any).serializeRequest(
|
|
444
|
+
'POST',
|
|
445
|
+
{ 'content-type': 'application/json' },
|
|
446
|
+
{ hello: 'world' },
|
|
447
|
+
new URL('https://example.com/api'),
|
|
448
|
+
nonce
|
|
449
|
+
)
|
|
450
|
+
expect(writer.toArray().length).toBeGreaterThan(32)
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
it('serializes a request with search params', async () => {
|
|
454
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
455
|
+
const nonce = new Array(32).fill(2)
|
|
456
|
+
const writer = await (authFetch as any).serializeRequest(
|
|
457
|
+
'GET',
|
|
458
|
+
{},
|
|
459
|
+
undefined,
|
|
460
|
+
new URL('https://example.com/api?q=hello'),
|
|
461
|
+
nonce
|
|
462
|
+
)
|
|
463
|
+
expect(writer.toArray().length).toBeGreaterThan(32)
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it('includes x-bsv-* custom headers', async () => {
|
|
467
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
468
|
+
const nonce = new Array(32).fill(3)
|
|
469
|
+
const writer = await (authFetch as any).serializeRequest(
|
|
470
|
+
'GET',
|
|
471
|
+
{ 'x-bsv-custom': 'value123' },
|
|
472
|
+
undefined,
|
|
473
|
+
new URL('https://example.com/'),
|
|
474
|
+
nonce
|
|
475
|
+
)
|
|
476
|
+
expect(writer.toArray().length).toBeGreaterThan(32)
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('includes authorization header', async () => {
|
|
480
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
481
|
+
const nonce = new Array(32).fill(4)
|
|
482
|
+
const writer = await (authFetch as any).serializeRequest(
|
|
483
|
+
'GET',
|
|
484
|
+
{ authorization: 'Bearer token123' },
|
|
485
|
+
undefined,
|
|
486
|
+
new URL('https://example.com/'),
|
|
487
|
+
nonce
|
|
488
|
+
)
|
|
489
|
+
expect(writer.toArray().length).toBeGreaterThan(32)
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
it('throws for x-bsv-auth-* headers', async () => {
|
|
493
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
494
|
+
const nonce = new Array(32).fill(5)
|
|
495
|
+
await expect(
|
|
496
|
+
(authFetch as any).serializeRequest(
|
|
497
|
+
'GET',
|
|
498
|
+
{ 'x-bsv-auth-identity-key': 'spoofed' },
|
|
499
|
+
undefined,
|
|
500
|
+
new URL('https://example.com/'),
|
|
501
|
+
nonce
|
|
502
|
+
)
|
|
503
|
+
).rejects.toThrow('No BSV auth headers allowed here!')
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it('throws for unsupported headers', async () => {
|
|
507
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
508
|
+
const nonce = new Array(32).fill(6)
|
|
509
|
+
await expect(
|
|
510
|
+
(authFetch as any).serializeRequest(
|
|
511
|
+
'GET',
|
|
512
|
+
{ 'accept': 'application/json' },
|
|
513
|
+
undefined,
|
|
514
|
+
new URL('https://example.com/'),
|
|
515
|
+
nonce
|
|
516
|
+
)
|
|
517
|
+
).rejects.toThrow('Unsupported header')
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
it('normalizes content-type by stripping parameters', async () => {
|
|
521
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
522
|
+
const nonce = new Array(32).fill(7)
|
|
523
|
+
// Should not throw — content-type is allowed but parameters are stripped
|
|
524
|
+
const writer = await (authFetch as any).serializeRequest(
|
|
525
|
+
'POST',
|
|
526
|
+
{ 'content-type': 'application/json; charset=utf-8' },
|
|
527
|
+
'{"x":1}',
|
|
528
|
+
new URL('https://example.com/api'),
|
|
529
|
+
nonce
|
|
530
|
+
)
|
|
531
|
+
expect(writer.toArray().length).toBeGreaterThan(32)
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it('defaults POST body to {} when content-type is application/json and body is undefined', async () => {
|
|
535
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
536
|
+
const nonce = new Array(32).fill(8)
|
|
537
|
+
// Should not throw
|
|
538
|
+
const writer = await (authFetch as any).serializeRequest(
|
|
539
|
+
'POST',
|
|
540
|
+
{ 'content-type': 'application/json' },
|
|
541
|
+
undefined,
|
|
542
|
+
new URL('https://example.com/api'),
|
|
543
|
+
nonce
|
|
544
|
+
)
|
|
545
|
+
expect(writer.toArray().length).toBeGreaterThan(32)
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
it('defaults DELETE body to empty string when no content-type and body is undefined', async () => {
|
|
549
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
550
|
+
const nonce = new Array(32).fill(9)
|
|
551
|
+
const writer = await (authFetch as any).serializeRequest(
|
|
552
|
+
'DELETE',
|
|
553
|
+
{},
|
|
554
|
+
undefined,
|
|
555
|
+
new URL('https://example.com/resource'),
|
|
556
|
+
nonce
|
|
557
|
+
)
|
|
558
|
+
expect(writer.toArray().length).toBeGreaterThan(32)
|
|
559
|
+
})
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
// 8. describeRequestBodyForLogging – all body types
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
describe('AuthFetch.describeRequestBodyForLogging (private)', () => {
|
|
567
|
+
let authFetch: AuthFetch
|
|
568
|
+
|
|
569
|
+
beforeEach(() => {
|
|
570
|
+
authFetch = new AuthFetch(buildWallet())
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
it('returns type=none for null body', () => {
|
|
574
|
+
const result = (authFetch as any).describeRequestBodyForLogging(null)
|
|
575
|
+
expect(result).toEqual({ type: 'none', byteLength: 0 })
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
it('returns type=none for undefined body', () => {
|
|
579
|
+
const result = (authFetch as any).describeRequestBodyForLogging(undefined)
|
|
580
|
+
expect(result).toEqual({ type: 'none', byteLength: 0 })
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
it('returns type=string with correct byteLength', () => {
|
|
584
|
+
const result = (authFetch as any).describeRequestBodyForLogging('hello')
|
|
585
|
+
expect(result.type).toBe('string')
|
|
586
|
+
expect(result.byteLength).toBe(5)
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
it('returns type=number[] for number array', () => {
|
|
590
|
+
const result = (authFetch as any).describeRequestBodyForLogging([1, 2, 3])
|
|
591
|
+
expect(result).toEqual({ type: 'number[]', byteLength: 3 })
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
it('returns type=array for non-number array', () => {
|
|
595
|
+
const result = (authFetch as any).describeRequestBodyForLogging(['a', 'b'])
|
|
596
|
+
expect(result).toEqual({ type: 'array', byteLength: 2 })
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
it('returns type=ArrayBuffer for ArrayBuffer', () => {
|
|
600
|
+
const buf = new ArrayBuffer(8)
|
|
601
|
+
const result = (authFetch as any).describeRequestBodyForLogging(buf)
|
|
602
|
+
expect(result).toEqual({ type: 'ArrayBuffer', byteLength: 8 })
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
it('returns typed array name for Uint8Array', () => {
|
|
606
|
+
const arr = new Uint8Array([1, 2, 3, 4])
|
|
607
|
+
const result = (authFetch as any).describeRequestBodyForLogging(arr)
|
|
608
|
+
expect(result.type).toBe('Uint8Array')
|
|
609
|
+
expect(result.byteLength).toBe(4)
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
it('returns type=Blob for Blob', () => {
|
|
613
|
+
const blob = new Blob(['hello world'])
|
|
614
|
+
const result = (authFetch as any).describeRequestBodyForLogging(blob)
|
|
615
|
+
expect(result.type).toBe('Blob')
|
|
616
|
+
expect(result.byteLength).toBeGreaterThan(0)
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
it('returns type=URLSearchParams for URLSearchParams', () => {
|
|
620
|
+
const params = new URLSearchParams({ key: 'value' })
|
|
621
|
+
const result = (authFetch as any).describeRequestBodyForLogging(params)
|
|
622
|
+
expect(result.type).toBe('URLSearchParams')
|
|
623
|
+
expect(result.byteLength).toBeGreaterThan(0)
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
it('returns type=FormData for FormData', () => {
|
|
627
|
+
const fd = new FormData()
|
|
628
|
+
fd.append('field', 'value')
|
|
629
|
+
const result = (authFetch as any).describeRequestBodyForLogging(fd)
|
|
630
|
+
expect(result.type).toBe('FormData')
|
|
631
|
+
expect(result.byteLength).toBe(0)
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
it('returns type=object for a plain object', () => {
|
|
635
|
+
const result = (authFetch as any).describeRequestBodyForLogging({ a: 1 })
|
|
636
|
+
expect(result.type).toBe('object')
|
|
637
|
+
expect(result.byteLength).toBeGreaterThan(0)
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
it('returns type=ReadableStream for ReadableStream', () => {
|
|
641
|
+
const stream = new ReadableStream()
|
|
642
|
+
const result = (authFetch as any).describeRequestBodyForLogging(stream)
|
|
643
|
+
expect(result).toEqual({ type: 'ReadableStream', byteLength: 0 })
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
it('falls back to typeof for an unrecognised type', () => {
|
|
647
|
+
// A Symbol cannot be JSON-stringified, triggering the fallback
|
|
648
|
+
const sym = Symbol('test')
|
|
649
|
+
const result = (authFetch as any).describeRequestBodyForLogging(sym)
|
|
650
|
+
expect(result.type).toBe('symbol')
|
|
651
|
+
expect(result.byteLength).toBe(0)
|
|
652
|
+
})
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
// ---------------------------------------------------------------------------
|
|
656
|
+
// 9. normalizeBodyToNumberArray – edge cases
|
|
657
|
+
// ---------------------------------------------------------------------------
|
|
658
|
+
|
|
659
|
+
describe('AuthFetch.normalizeBodyToNumberArray (private)', () => {
|
|
660
|
+
let authFetch: AuthFetch
|
|
661
|
+
|
|
662
|
+
beforeEach(() => {
|
|
663
|
+
authFetch = new AuthFetch(buildWallet())
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
it('returns [] for null', async () => {
|
|
667
|
+
const result = await (authFetch as any).normalizeBodyToNumberArray(null)
|
|
668
|
+
expect(result).toEqual([])
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
it('returns [] for undefined', async () => {
|
|
672
|
+
const result = await (authFetch as any).normalizeBodyToNumberArray(undefined)
|
|
673
|
+
expect(result).toEqual([])
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
it('converts a string to a number array', async () => {
|
|
677
|
+
const result = await (authFetch as any).normalizeBodyToNumberArray('abc')
|
|
678
|
+
expect(result.length).toBe(3)
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
it('converts a number[] to a JSON-encoded number array', async () => {
|
|
682
|
+
// Arrays are objects, so they hit the typeof === 'object' branch first
|
|
683
|
+
// and are serialized via JSON.stringify before the number[] guard runs.
|
|
684
|
+
const input = [1, 2, 3]
|
|
685
|
+
const result = await (authFetch as any).normalizeBodyToNumberArray(input)
|
|
686
|
+
// '[1,2,3]' encoded as UTF-8 bytes
|
|
687
|
+
const expected = Utils.toArray(JSON.stringify(input), 'utf8')
|
|
688
|
+
expect(result).toEqual(expected)
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
it('converts ArrayBuffer to a JSON-encoded number array', async () => {
|
|
692
|
+
// ArrayBuffer is an object, so it hits the typeof === 'object' branch first.
|
|
693
|
+
const buf = new Uint8Array([10, 20, 30]).buffer
|
|
694
|
+
const result = await (authFetch as any).normalizeBodyToNumberArray(buf)
|
|
695
|
+
// JSON.stringify of an ArrayBuffer produces '{}'
|
|
696
|
+
const expected = Utils.toArray(JSON.stringify(buf), 'utf8')
|
|
697
|
+
expect(result).toEqual(expected)
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
it('converts Uint8Array to a JSON-encoded number array', async () => {
|
|
701
|
+
// Uint8Array is an object, so it hits the typeof === 'object' branch first.
|
|
702
|
+
const arr = new Uint8Array([5, 6, 7])
|
|
703
|
+
const result = await (authFetch as any).normalizeBodyToNumberArray(arr)
|
|
704
|
+
// JSON.stringify of a Uint8Array produces e.g. '{"0":5,"1":6,"2":7}'
|
|
705
|
+
const expected = Utils.toArray(JSON.stringify(arr), 'utf8')
|
|
706
|
+
expect(result).toEqual(expected)
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
it('converts Blob via JSON.stringify (object branch)', async () => {
|
|
710
|
+
// Blob is an object — hits the typeof === 'object' branch before the Blob check.
|
|
711
|
+
const blob = new Blob(['hi'])
|
|
712
|
+
const result = await (authFetch as any).normalizeBodyToNumberArray(blob)
|
|
713
|
+
// JSON.stringify(new Blob(...)) → '{}'
|
|
714
|
+
const expected = Utils.toArray(JSON.stringify(blob), 'utf8')
|
|
715
|
+
expect(result).toEqual(expected)
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
it('converts FormData via JSON.stringify (object branch)', async () => {
|
|
719
|
+
// FormData is an object — hits the typeof === 'object' branch before the FormData check.
|
|
720
|
+
const fd = new FormData()
|
|
721
|
+
fd.append('name', 'alice')
|
|
722
|
+
const result = await (authFetch as any).normalizeBodyToNumberArray(fd)
|
|
723
|
+
// JSON.stringify(FormData) → '{}'
|
|
724
|
+
const expected = Utils.toArray(JSON.stringify(fd), 'utf8')
|
|
725
|
+
expect(result).toEqual(expected)
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
it('converts URLSearchParams via JSON.stringify (object branch)', async () => {
|
|
729
|
+
// URLSearchParams is an object — hits typeof === 'object' branch first.
|
|
730
|
+
const params = new URLSearchParams({ q: 'hello' })
|
|
731
|
+
const result = await (authFetch as any).normalizeBodyToNumberArray(params)
|
|
732
|
+
// JSON.stringify(URLSearchParams) → '{}'
|
|
733
|
+
const expected = Utils.toArray(JSON.stringify(params), 'utf8')
|
|
734
|
+
expect(result).toEqual(expected)
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
it('converts ReadableStream via JSON.stringify (object branch)', async () => {
|
|
738
|
+
// ReadableStream is an object, so it hits the typeof === 'object' branch first
|
|
739
|
+
// and is serialized via JSON.stringify (produces '{}') rather than throwing.
|
|
740
|
+
const stream = new ReadableStream()
|
|
741
|
+
const result = await (authFetch as any).normalizeBodyToNumberArray(stream)
|
|
742
|
+
const expected = Utils.toArray(JSON.stringify(stream), 'utf8')
|
|
743
|
+
expect(result).toEqual(expected)
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
it('converts a plain object via JSON.stringify', async () => {
|
|
747
|
+
const obj = { key: 'value' }
|
|
748
|
+
const result = await (authFetch as any).normalizeBodyToNumberArray(obj)
|
|
749
|
+
expect(result.length).toBeGreaterThan(0)
|
|
750
|
+
})
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
// ---------------------------------------------------------------------------
|
|
754
|
+
// 10. getMaxPaymentAttempts
|
|
755
|
+
// ---------------------------------------------------------------------------
|
|
756
|
+
|
|
757
|
+
describe('AuthFetch.getMaxPaymentAttempts (private)', () => {
|
|
758
|
+
let authFetch: AuthFetch
|
|
759
|
+
|
|
760
|
+
beforeEach(() => {
|
|
761
|
+
authFetch = new AuthFetch(buildWallet())
|
|
762
|
+
})
|
|
763
|
+
|
|
764
|
+
it('returns 3 by default', () => {
|
|
765
|
+
expect((authFetch as any).getMaxPaymentAttempts({})).toBe(3)
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
it('returns the configured positive integer', () => {
|
|
769
|
+
expect((authFetch as any).getMaxPaymentAttempts({ paymentRetryAttempts: 5 })).toBe(5)
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
it('floors the value for a float', () => {
|
|
773
|
+
expect((authFetch as any).getMaxPaymentAttempts({ paymentRetryAttempts: 4.9 })).toBe(4)
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
it('returns 3 when paymentRetryAttempts is 0', () => {
|
|
777
|
+
expect((authFetch as any).getMaxPaymentAttempts({ paymentRetryAttempts: 0 })).toBe(3)
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
it('returns 3 when paymentRetryAttempts is negative', () => {
|
|
781
|
+
expect((authFetch as any).getMaxPaymentAttempts({ paymentRetryAttempts: -1 })).toBe(3)
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
it('returns 3 when paymentRetryAttempts is a string', () => {
|
|
785
|
+
expect((authFetch as any).getMaxPaymentAttempts({ paymentRetryAttempts: 'five' as any })).toBe(3)
|
|
786
|
+
})
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
// ---------------------------------------------------------------------------
|
|
790
|
+
// 11. getPaymentRetryDelay
|
|
791
|
+
// ---------------------------------------------------------------------------
|
|
792
|
+
|
|
793
|
+
describe('AuthFetch.getPaymentRetryDelay (private)', () => {
|
|
794
|
+
let authFetch: AuthFetch
|
|
795
|
+
|
|
796
|
+
beforeEach(() => {
|
|
797
|
+
authFetch = new AuthFetch(buildWallet())
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
it('returns 250 for attempt 1 (250 * 1)', () => {
|
|
801
|
+
expect((authFetch as any).getPaymentRetryDelay(1)).toBe(250)
|
|
802
|
+
})
|
|
803
|
+
|
|
804
|
+
it('returns 500 for attempt 2', () => {
|
|
805
|
+
expect((authFetch as any).getPaymentRetryDelay(2)).toBe(500)
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
it('caps multiplier at 5 for attempt >= 5', () => {
|
|
809
|
+
expect((authFetch as any).getPaymentRetryDelay(5)).toBe(1250)
|
|
810
|
+
expect((authFetch as any).getPaymentRetryDelay(10)).toBe(1250)
|
|
811
|
+
expect((authFetch as any).getPaymentRetryDelay(100)).toBe(1250)
|
|
812
|
+
})
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
// ---------------------------------------------------------------------------
|
|
816
|
+
// 12. wait()
|
|
817
|
+
// ---------------------------------------------------------------------------
|
|
818
|
+
|
|
819
|
+
describe('AuthFetch.wait (private)', () => {
|
|
820
|
+
it('resolves immediately for ms <= 0', async () => {
|
|
821
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
822
|
+
const start = Date.now()
|
|
823
|
+
await (authFetch as any).wait(0)
|
|
824
|
+
expect(Date.now() - start).toBeLessThan(50)
|
|
825
|
+
})
|
|
826
|
+
|
|
827
|
+
it('resolves immediately for negative ms', async () => {
|
|
828
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
829
|
+
const start = Date.now()
|
|
830
|
+
await (authFetch as any).wait(-100)
|
|
831
|
+
expect(Date.now() - start).toBeLessThan(50)
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
it('uses a timer for positive ms', async () => {
|
|
835
|
+
jest.useFakeTimers()
|
|
836
|
+
try {
|
|
837
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
838
|
+
let resolved = false
|
|
839
|
+
const promise = (authFetch as any).wait(500).then(() => { resolved = true })
|
|
840
|
+
expect(resolved).toBe(false)
|
|
841
|
+
await jest.advanceTimersByTimeAsync(500)
|
|
842
|
+
await promise
|
|
843
|
+
expect(resolved).toBe(true)
|
|
844
|
+
} finally {
|
|
845
|
+
jest.useRealTimers()
|
|
846
|
+
}
|
|
847
|
+
})
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
// ---------------------------------------------------------------------------
|
|
851
|
+
// 13. isPaymentContextCompatible
|
|
852
|
+
// ---------------------------------------------------------------------------
|
|
853
|
+
|
|
854
|
+
describe('AuthFetch.isPaymentContextCompatible (private)', () => {
|
|
855
|
+
let authFetch: AuthFetch
|
|
856
|
+
|
|
857
|
+
beforeEach(() => {
|
|
858
|
+
authFetch = new AuthFetch(buildWallet())
|
|
859
|
+
})
|
|
860
|
+
|
|
861
|
+
function makeCtx (overrides: Partial<any> = {}): any {
|
|
862
|
+
return {
|
|
863
|
+
satoshisRequired: 10,
|
|
864
|
+
serverIdentityKey: 'srv',
|
|
865
|
+
derivationPrefix: 'pfx',
|
|
866
|
+
...overrides
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
it('returns true when all fields match', () => {
|
|
871
|
+
const ctx = makeCtx()
|
|
872
|
+
expect((authFetch as any).isPaymentContextCompatible(ctx, 10, 'srv', 'pfx')).toBe(true)
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
it('returns false when satoshis differ', () => {
|
|
876
|
+
const ctx = makeCtx()
|
|
877
|
+
expect((authFetch as any).isPaymentContextCompatible(ctx, 20, 'srv', 'pfx')).toBe(false)
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
it('returns false when serverIdentityKey differs', () => {
|
|
881
|
+
const ctx = makeCtx()
|
|
882
|
+
expect((authFetch as any).isPaymentContextCompatible(ctx, 10, 'other', 'pfx')).toBe(false)
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
it('returns false when derivationPrefix differs', () => {
|
|
886
|
+
const ctx = makeCtx()
|
|
887
|
+
expect((authFetch as any).isPaymentContextCompatible(ctx, 10, 'srv', 'other')).toBe(false)
|
|
888
|
+
})
|
|
889
|
+
})
|
|
890
|
+
|
|
891
|
+
// ---------------------------------------------------------------------------
|
|
892
|
+
// 14. consumeReceivedCertificates
|
|
893
|
+
// ---------------------------------------------------------------------------
|
|
894
|
+
|
|
895
|
+
describe('AuthFetch.consumeReceivedCertificates', () => {
|
|
896
|
+
it('returns all received certs and empties the buffer', () => {
|
|
897
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
898
|
+
;(authFetch as any).certificatesReceived.push(
|
|
899
|
+
{ serialNumber: 'cert1' },
|
|
900
|
+
{ serialNumber: 'cert2' }
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
const certs = authFetch.consumeReceivedCertificates()
|
|
904
|
+
expect(certs).toHaveLength(2)
|
|
905
|
+
expect(certs[0]).toMatchObject({ serialNumber: 'cert1' })
|
|
906
|
+
|
|
907
|
+
// Buffer should now be empty
|
|
908
|
+
const second = authFetch.consumeReceivedCertificates()
|
|
909
|
+
expect(second).toHaveLength(0)
|
|
910
|
+
})
|
|
911
|
+
|
|
912
|
+
it('returns an empty array when no certs have been received', () => {
|
|
913
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
914
|
+
expect(authFetch.consumeReceivedCertificates()).toEqual([])
|
|
915
|
+
})
|
|
916
|
+
})
|
|
917
|
+
|
|
918
|
+
// ---------------------------------------------------------------------------
|
|
919
|
+
// 15. sendCertificateRequest – creates a new peer when none exists
|
|
920
|
+
// ---------------------------------------------------------------------------
|
|
921
|
+
|
|
922
|
+
describe('AuthFetch.sendCertificateRequest – new peer creation', () => {
|
|
923
|
+
it('creates a new Peer transport when no peer exists for the base URL', async () => {
|
|
924
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
925
|
+
|
|
926
|
+
// Verify there is no existing peer
|
|
927
|
+
expect((authFetch as any).peers['https://new-server.com']).toBeUndefined()
|
|
928
|
+
|
|
929
|
+
const fakeCerts = [{ serialNumber: 'abc' }]
|
|
930
|
+
let capturedListener: ((senderKey: string, certs: any[]) => void) | undefined
|
|
931
|
+
|
|
932
|
+
const peerProto = {
|
|
933
|
+
listenForCertificatesReceived: jest.fn((cb: any) => {
|
|
934
|
+
capturedListener = cb
|
|
935
|
+
return 99
|
|
936
|
+
}),
|
|
937
|
+
stopListeningForCertificatesReceived: jest.fn(),
|
|
938
|
+
requestCertificates: jest.fn(async () => {
|
|
939
|
+
capturedListener?.('srv-key', fakeCerts as any)
|
|
940
|
+
})
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Intercept Peer constructor by injecting peer directly after fetch call starts
|
|
944
|
+
const origFetch = authFetch.sendCertificateRequest.bind(authFetch)
|
|
945
|
+
jest.spyOn(authFetch as any, 'sendCertificateRequest').mockImplementationOnce(
|
|
946
|
+
async (url: string, certs: any) => {
|
|
947
|
+
// Manually inject our stub peer so Peer constructor is bypassed
|
|
948
|
+
;(authFetch as any).peers['https://new-server.com'] = {
|
|
949
|
+
peer: peerProto,
|
|
950
|
+
pendingCertificateRequests: []
|
|
951
|
+
}
|
|
952
|
+
return origFetch(url, certs)
|
|
953
|
+
}
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
const result = await authFetch.sendCertificateRequest(
|
|
957
|
+
'https://new-server.com/path',
|
|
958
|
+
{ certifiers: [], types: {} } as any
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
expect(result).toHaveLength(1)
|
|
962
|
+
})
|
|
963
|
+
})
|
|
964
|
+
|
|
965
|
+
// ---------------------------------------------------------------------------
|
|
966
|
+
// 16. logPaymentAttempt – all levels
|
|
967
|
+
// ---------------------------------------------------------------------------
|
|
968
|
+
|
|
969
|
+
describe('AuthFetch.logPaymentAttempt (private)', () => {
|
|
970
|
+
let authFetch: AuthFetch
|
|
971
|
+
|
|
972
|
+
beforeEach(() => {
|
|
973
|
+
authFetch = new AuthFetch(buildWallet())
|
|
974
|
+
})
|
|
975
|
+
|
|
976
|
+
it('calls console.error for level=error', () => {
|
|
977
|
+
const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
978
|
+
;(authFetch as any).logPaymentAttempt('error', 'test error', { a: 1 })
|
|
979
|
+
expect(spy).toHaveBeenCalledWith('[AuthFetch][Payment] test error', { a: 1 })
|
|
980
|
+
})
|
|
981
|
+
|
|
982
|
+
it('calls console.warn for level=warn', () => {
|
|
983
|
+
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
984
|
+
;(authFetch as any).logPaymentAttempt('warn', 'test warn', { b: 2 })
|
|
985
|
+
expect(spy).toHaveBeenCalledWith('[AuthFetch][Payment] test warn', { b: 2 })
|
|
986
|
+
})
|
|
987
|
+
|
|
988
|
+
it('calls console.info for level=info when available', () => {
|
|
989
|
+
const spy = jest.spyOn(console, 'info').mockImplementation(() => {})
|
|
990
|
+
;(authFetch as any).logPaymentAttempt('info', 'test info', { c: 3 })
|
|
991
|
+
expect(spy).toHaveBeenCalledWith('[AuthFetch][Payment] test info', { c: 3 })
|
|
992
|
+
})
|
|
993
|
+
|
|
994
|
+
it('falls back to console.log for level=info when console.info is not a function', () => {
|
|
995
|
+
const originalInfo = console.info
|
|
996
|
+
;(console as any).info = undefined
|
|
997
|
+
const spy = jest.spyOn(console, 'log').mockImplementation(() => {})
|
|
998
|
+
try {
|
|
999
|
+
;(authFetch as any).logPaymentAttempt('info', 'fallback log', {})
|
|
1000
|
+
expect(spy).toHaveBeenCalledWith('[AuthFetch][Payment] fallback log', {})
|
|
1001
|
+
} finally {
|
|
1002
|
+
console.info = originalInfo
|
|
1003
|
+
}
|
|
1004
|
+
})
|
|
1005
|
+
})
|
|
1006
|
+
|
|
1007
|
+
// ---------------------------------------------------------------------------
|
|
1008
|
+
// 17. createPaymentErrorEntry
|
|
1009
|
+
// ---------------------------------------------------------------------------
|
|
1010
|
+
|
|
1011
|
+
describe('AuthFetch.createPaymentErrorEntry (private)', () => {
|
|
1012
|
+
let authFetch: AuthFetch
|
|
1013
|
+
|
|
1014
|
+
beforeEach(() => {
|
|
1015
|
+
authFetch = new AuthFetch(buildWallet())
|
|
1016
|
+
})
|
|
1017
|
+
|
|
1018
|
+
it('extracts message and stack from an Error instance', () => {
|
|
1019
|
+
const err = new Error('something went wrong')
|
|
1020
|
+
const entry = (authFetch as any).createPaymentErrorEntry(2, err)
|
|
1021
|
+
expect(entry.attempt).toBe(2)
|
|
1022
|
+
expect(entry.message).toBe('something went wrong')
|
|
1023
|
+
expect(typeof entry.stack).toBe('string')
|
|
1024
|
+
expect(typeof entry.timestamp).toBe('string')
|
|
1025
|
+
})
|
|
1026
|
+
|
|
1027
|
+
it('converts non-Error to string message', () => {
|
|
1028
|
+
const entry = (authFetch as any).createPaymentErrorEntry(1, 'just a string error')
|
|
1029
|
+
expect(entry.message).toBe('just a string error')
|
|
1030
|
+
expect(entry.stack).toBeUndefined()
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
it('converts numeric error to string message', () => {
|
|
1034
|
+
const entry = (authFetch as any).createPaymentErrorEntry(1, 42)
|
|
1035
|
+
expect(entry.message).toBe('42')
|
|
1036
|
+
})
|
|
1037
|
+
})
|
|
1038
|
+
|
|
1039
|
+
// ---------------------------------------------------------------------------
|
|
1040
|
+
// 18. buildPaymentFailureError
|
|
1041
|
+
// ---------------------------------------------------------------------------
|
|
1042
|
+
|
|
1043
|
+
describe('AuthFetch.buildPaymentFailureError (private)', () => {
|
|
1044
|
+
let authFetch: AuthFetch
|
|
1045
|
+
|
|
1046
|
+
beforeEach(() => {
|
|
1047
|
+
authFetch = new AuthFetch(buildWallet())
|
|
1048
|
+
})
|
|
1049
|
+
|
|
1050
|
+
function makeContext (): any {
|
|
1051
|
+
return {
|
|
1052
|
+
satoshisRequired: 10,
|
|
1053
|
+
transactionBase64: 'tx-base64',
|
|
1054
|
+
derivationPrefix: 'pfx',
|
|
1055
|
+
derivationSuffix: 'sfx',
|
|
1056
|
+
serverIdentityKey: 'srv',
|
|
1057
|
+
clientIdentityKey: 'cli',
|
|
1058
|
+
attempts: 3,
|
|
1059
|
+
maxAttempts: 3,
|
|
1060
|
+
errors: [],
|
|
1061
|
+
requestSummary: { url: 'https://ex.com', method: 'GET', headers: {}, bodyType: 'none', bodyByteLength: 0 }
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
it('creates an Error with a descriptive message', () => {
|
|
1066
|
+
const err = (authFetch as any).buildPaymentFailureError(
|
|
1067
|
+
'https://example.com/pay',
|
|
1068
|
+
makeContext(),
|
|
1069
|
+
new Error('last error')
|
|
1070
|
+
)
|
|
1071
|
+
expect(err).toBeInstanceOf(Error)
|
|
1072
|
+
expect(err.message).toContain('https://example.com/pay')
|
|
1073
|
+
expect(err.message).toContain('3/3')
|
|
1074
|
+
expect(err.message).toContain('10 satoshis')
|
|
1075
|
+
})
|
|
1076
|
+
|
|
1077
|
+
it('attaches details to the error', () => {
|
|
1078
|
+
const err = (authFetch as any).buildPaymentFailureError(
|
|
1079
|
+
'https://example.com/pay',
|
|
1080
|
+
makeContext(),
|
|
1081
|
+
new Error('x')
|
|
1082
|
+
)
|
|
1083
|
+
expect(err.details).toBeDefined()
|
|
1084
|
+
expect(err.details.payment.satoshis).toBe(10)
|
|
1085
|
+
expect(err.details.attempts.used).toBe(3)
|
|
1086
|
+
})
|
|
1087
|
+
|
|
1088
|
+
it('sets cause when lastError is an Error', () => {
|
|
1089
|
+
const cause = new Error('root cause')
|
|
1090
|
+
const err = (authFetch as any).buildPaymentFailureError(
|
|
1091
|
+
'https://example.com/pay',
|
|
1092
|
+
makeContext(),
|
|
1093
|
+
cause
|
|
1094
|
+
)
|
|
1095
|
+
expect(err.cause).toBe(cause)
|
|
1096
|
+
})
|
|
1097
|
+
|
|
1098
|
+
it('does not set cause when lastError is a string', () => {
|
|
1099
|
+
const err = (authFetch as any).buildPaymentFailureError(
|
|
1100
|
+
'https://example.com/pay',
|
|
1101
|
+
makeContext(),
|
|
1102
|
+
'string error'
|
|
1103
|
+
)
|
|
1104
|
+
expect(err.cause).toBeUndefined()
|
|
1105
|
+
})
|
|
1106
|
+
})
|
|
1107
|
+
|
|
1108
|
+
// ---------------------------------------------------------------------------
|
|
1109
|
+
// 19. buildPaymentRequestSummary
|
|
1110
|
+
// ---------------------------------------------------------------------------
|
|
1111
|
+
|
|
1112
|
+
describe('AuthFetch.buildPaymentRequestSummary (private)', () => {
|
|
1113
|
+
it('builds a summary with correct fields', () => {
|
|
1114
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
1115
|
+
const summary = (authFetch as any).buildPaymentRequestSummary(
|
|
1116
|
+
'https://example.com/resource',
|
|
1117
|
+
{ method: 'post', headers: { 'X-Custom': 'value' }, body: 'hello' }
|
|
1118
|
+
)
|
|
1119
|
+
expect(summary.url).toBe('https://example.com/resource')
|
|
1120
|
+
expect(summary.method).toBe('POST')
|
|
1121
|
+
expect(summary.headers).toMatchObject({ 'X-Custom': 'value' })
|
|
1122
|
+
expect(summary.bodyType).toBe('string')
|
|
1123
|
+
expect(summary.bodyByteLength).toBe(5)
|
|
1124
|
+
})
|
|
1125
|
+
|
|
1126
|
+
it('defaults method to GET when not provided', () => {
|
|
1127
|
+
const authFetch = new AuthFetch(buildWallet())
|
|
1128
|
+
const summary = (authFetch as any).buildPaymentRequestSummary('https://example.com', {})
|
|
1129
|
+
expect(summary.method).toBe('GET')
|
|
1130
|
+
})
|
|
1131
|
+
})
|