/** * AuthFetch additional tests. * * Covers branches not exercised by the primary AuthFetch.test.ts: * - fetch(): retryCounter exhaustion, non-auth fallback path, stale-session retry * - serializeRequest(): all header/body/URL variants * - handleFetchAndValidate(): success, x-bsv header spoofing, non-ok response * - handlePaymentAndRetry(): missing/invalid headers, incompatible context regeneration * - describeRequestBodyForLogging(): all body types * - getMaxPaymentAttempts(): edge cases * - getPaymentRetryDelay(): values at different attempt counts * - wait(): zero / positive * - isPaymentContextCompatible(): match / mismatch branches * - consumeReceivedCertificates(): drains the internal buffer * - sendCertificateRequest(): creates new peer when none exists * - logPaymentAttempt(): all three log levels * - createPaymentErrorEntry(): Error vs non-Error values * - buildPaymentFailureError(): shapes the error correctly */ import { jest } from '@jest/globals' import { AuthFetch } from '../AuthFetch.js' import { Utils, PrivateKey } from '../../../primitives/index.js' // --------------------------------------------------------------------------- // Module mock for createNonce (matches what primary test does) // --------------------------------------------------------------------------- jest.mock('../../utils/createNonce.js', () => ({ createNonce: jest.fn() })) import { createNonce } from '../../utils/createNonce.js' const createNonceMock = createNonce as jest.MockedFunction // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function buildWallet (): any { const identityKey = new PrivateKey(10).toPublicKey().toString() const derivedKey = new PrivateKey(11).toPublicKey().toString() return { getPublicKey: jest.fn(async (opts: any) => opts?.identityKey === true ? { publicKey: identityKey } : { publicKey: derivedKey } ), createAction: jest.fn(async () => ({ tx: Utils.toArray('mock-tx', 'utf8') })), createHmac: jest.fn(async () => ({ hmac: new Array(32).fill(0) })) } } function make402Response (overrides: Record = {}): Response { const headers: Record = { 'x-bsv-payment-version': '1.0', 'x-bsv-payment-satoshis-required': '10', 'x-bsv-auth-identity-key': 'srv-key', 'x-bsv-payment-derivation-prefix': 'pfx', ...overrides } return new Response('', { status: 402, headers }) } afterEach(() => { jest.restoreAllMocks() createNonceMock.mockReset() }) // --------------------------------------------------------------------------- // 1. fetch() – retryCounter exhaustion // --------------------------------------------------------------------------- describe('AuthFetch.fetch – retryCounter', () => { it('throws when retryCounter reaches 0', async () => { const authFetch = new AuthFetch(buildWallet()) await expect( authFetch.fetch('https://example.com', { retryCounter: 0 }) ).rejects.toThrow('Request failed after maximum number of retries.') }) it('decrements retryCounter before making the request', async () => { // Verify that the stale-session retry path calls fetch() again, which means // retryCounter gets decremented. We intercept the recursive fetch() call using // a spy so a real Peer is never constructed inside the unit-test environment. const authFetch = new AuthFetch(buildWallet()) let fetchCallCount = 0 const originalFetch = authFetch.fetch.bind(authFetch) jest.spyOn(authFetch, 'fetch').mockImplementation(async (url, config) => { fetchCallCount++ if (fetchCallCount === 1) { // First call: run the real code path so the stale-session branch triggers return originalFetch(url, config) } // Subsequent calls (recursive retry after stale-session): throw to prove // the retry occurred with a decremented retryCounter. throw new Error('second call') }) // Inject a stub peer that throws a stale-session error on toPeer() const peerStub = { listenForCertificatesReceived: jest.fn(), listenForCertificatesRequested: jest.fn(), listenForGeneralMessages: jest.fn(() => 1), stopListeningForGeneralMessages: jest.fn(), toPeer: jest.fn(async () => { throw new Error('Session not found for nonce xyz') }) } ;(authFetch as any).peers['https://example.com'] = { peer: peerStub, identityKey: 'some-key', supportsMutualAuth: true, pendingCertificateRequests: [] } // With retryCounter: 2, the stale-session branch retries once; the spy // intercepts the recursive call and throws 'second call'. await expect( authFetch.fetch('https://example.com/path', { retryCounter: 2 }) ).rejects.toThrow('second call') expect(fetchCallCount).toBe(2) }) }) // --------------------------------------------------------------------------- // 2. fetch() – supportsMutualAuth === false fallback // --------------------------------------------------------------------------- describe('AuthFetch.fetch – non-auth fallback (supportsMutualAuth=false)', () => { it('falls back to handleFetchAndValidate when peer does not support mutual auth', async () => { const authFetch = new AuthFetch(buildWallet()) const handleFetchSpy = jest .spyOn(authFetch as any, 'handleFetchAndValidate') .mockResolvedValue(new Response('ok', { status: 200 })) const peerStub = { peer: { toPeer: jest.fn() }, supportsMutualAuth: false, pendingCertificateRequests: [] } ;(authFetch as any).peers['https://example.com'] = peerStub const result = await authFetch.fetch('https://example.com/resource') expect(handleFetchSpy).toHaveBeenCalledTimes(1) expect(result.status).toBe(200) }) it('rejects when handleFetchAndValidate throws in non-auth fallback', async () => { const authFetch = new AuthFetch(buildWallet()) jest .spyOn(authFetch as any, 'handleFetchAndValidate') .mockRejectedValue(new Error('fetch validation failed')) const peerStub = { peer: { toPeer: jest.fn() }, supportsMutualAuth: false, pendingCertificateRequests: [] } ;(authFetch as any).peers['https://example.com'] = peerStub await expect(authFetch.fetch('https://example.com/resource')).rejects.toThrow( 'fetch validation failed' ) }) }) // --------------------------------------------------------------------------- // 3. handleFetchAndValidate // --------------------------------------------------------------------------- describe('AuthFetch.handleFetchAndValidate (private)', () => { it('returns the response when fetch succeeds with no x-bsv headers', async () => { const authFetch = new AuthFetch(buildWallet()) const mockResponse = new Response('body', { status: 200, headers: { 'Content-Type': 'text/plain' } }) jest.spyOn(global, 'fetch').mockResolvedValue(mockResponse) const peerToUse: any = { supportsMutualAuth: undefined } const response = await (authFetch as any).handleFetchAndValidate( 'https://example.com', { method: 'GET' }, peerToUse ) expect(response.status).toBe(200) expect(peerToUse.supportsMutualAuth).toBe(false) }) it('throws when response contains an x-bsv header (spoofing detection)', async () => { const authFetch = new AuthFetch(buildWallet()) // The source iterates response.headers.forEach((value, name) => ...) // and checks if the VALUE starts with 'x-bsv'. To trigger spoofing // detection we need a header whose value starts with 'x-bsv'. const mockResponse = new Response('', { status: 200, headers: { 'x-custom-header': 'x-bsv-auth-identity-key' } }) jest.spyOn(global, 'fetch').mockResolvedValue(mockResponse) await expect( (authFetch as any).handleFetchAndValidate( 'https://example.com', {}, { supportsMutualAuth: undefined } ) ).rejects.toThrow('The server is trying to claim it has been authenticated') }) it('throws when response is not ok', async () => { const authFetch = new AuthFetch(buildWallet()) const mockResponse = new Response('Not Found', { status: 404 }) jest.spyOn(global, 'fetch').mockResolvedValue(mockResponse) await expect( (authFetch as any).handleFetchAndValidate( 'https://example.com', {}, { supportsMutualAuth: undefined } ) ).rejects.toThrow('Request failed with status: 404') }) }) // --------------------------------------------------------------------------- // 4. handlePaymentAndRetry – missing/invalid headers // --------------------------------------------------------------------------- describe('AuthFetch.handlePaymentAndRetry – header validation', () => { it('throws when x-bsv-payment-version header is missing', async () => { const authFetch = new AuthFetch(buildWallet()) const response = new Response('', { status: 402 }) // no payment headers await expect( (authFetch as any).handlePaymentAndRetry('https://example.com', {}, response) ).rejects.toThrow('Unsupported x-bsv-payment-version response header') }) it('throws when x-bsv-payment-version header has wrong value', async () => { const authFetch = new AuthFetch(buildWallet()) const response = make402Response({ 'x-bsv-payment-version': '2.0' }) await expect( (authFetch as any).handlePaymentAndRetry('https://example.com', {}, response) ).rejects.toThrow('Unsupported x-bsv-payment-version response header') }) it('throws when x-bsv-payment-satoshis-required header is missing', async () => { const authFetch = new AuthFetch(buildWallet()) const response = make402Response({ 'x-bsv-payment-satoshis-required': '' }) // Force re-check: create a response without the satoshis header entirely const headersRaw: Record = { 'x-bsv-payment-version': '1.0', 'x-bsv-auth-identity-key': 'srv-key', 'x-bsv-payment-derivation-prefix': 'pfx' // satoshis-required intentionally omitted } const respNoSatoshis = new Response('', { status: 402, headers: headersRaw }) await expect( (authFetch as any).handlePaymentAndRetry('https://example.com', {}, respNoSatoshis) ).rejects.toThrow('Missing x-bsv-payment-satoshis-required response header') }) it('throws when satoshis value is NaN', async () => { const authFetch = new AuthFetch(buildWallet()) const response = make402Response({ 'x-bsv-payment-satoshis-required': 'not-a-number' }) await expect( (authFetch as any).handlePaymentAndRetry('https://example.com', {}, response) ).rejects.toThrow('Invalid x-bsv-payment-satoshis-required response header value') }) it('throws when satoshis value is zero', async () => { const authFetch = new AuthFetch(buildWallet()) const response = make402Response({ 'x-bsv-payment-satoshis-required': '0' }) await expect( (authFetch as any).handlePaymentAndRetry('https://example.com', {}, response) ).rejects.toThrow('Invalid x-bsv-payment-satoshis-required response header value') }) it('throws when x-bsv-auth-identity-key header is missing', async () => { const authFetch = new AuthFetch(buildWallet()) const headersRaw: Record = { 'x-bsv-payment-version': '1.0', 'x-bsv-payment-satoshis-required': '10', 'x-bsv-payment-derivation-prefix': 'pfx' // identity-key omitted } const response = new Response('', { status: 402, headers: headersRaw }) await expect( (authFetch as any).handlePaymentAndRetry('https://example.com', {}, response) ).rejects.toThrow('Missing x-bsv-auth-identity-key response header') }) it('throws when x-bsv-payment-derivation-prefix header is missing', async () => { const authFetch = new AuthFetch(buildWallet()) const headersRaw: Record = { 'x-bsv-payment-version': '1.0', 'x-bsv-payment-satoshis-required': '10', 'x-bsv-auth-identity-key': 'srv-key' // derivation-prefix omitted } const response = new Response('', { status: 402, headers: headersRaw }) await expect( (authFetch as any).handlePaymentAndRetry('https://example.com', {}, response) ).rejects.toThrow('Missing x-bsv-payment-derivation-prefix response header') }) it('throws when derivation-prefix is an empty string', async () => { const authFetch = new AuthFetch(buildWallet()) const response = make402Response({ 'x-bsv-payment-derivation-prefix': '' }) await expect( (authFetch as any).handlePaymentAndRetry('https://example.com', {}, response) ).rejects.toThrow('Missing x-bsv-payment-derivation-prefix response header') }) }) // --------------------------------------------------------------------------- // 5. handlePaymentAndRetry – incompatible context triggers new context creation // --------------------------------------------------------------------------- describe('AuthFetch.handlePaymentAndRetry – context compatibility', () => { it('regenerates context when server changes payment requirements', async () => { const authFetch = new AuthFetch(buildWallet()) jest.spyOn(authFetch as any, 'logPaymentAttempt').mockImplementation(() => {}) jest.spyOn(authFetch as any, 'wait').mockResolvedValue(undefined) createNonceMock.mockResolvedValue('new-suffix') const existingContext = { satoshisRequired: 5, // server now asks for 10 transactionBase64: Utils.toBase64([1, 2, 3]), derivationPrefix: 'pfx', derivationSuffix: 'old-suffix', serverIdentityKey: 'srv-key', clientIdentityKey: 'client-key', attempts: 0, maxAttempts: 3, errors: [], requestSummary: { url: 'https://example.com', method: 'GET', headers: {}, bodyType: 'none', bodyByteLength: 0 } } const fetchSpy = jest .spyOn(authFetch, 'fetch') .mockResolvedValue(new Response('ok', { status: 200 })) const response = make402Response({ 'x-bsv-payment-satoshis-required': '10' }) // changed from 5 await (authFetch as any).handlePaymentAndRetry( 'https://example.com', { paymentContext: existingContext }, response ) // createNonce should have been called because the context was regenerated expect(createNonceMock).toHaveBeenCalled() expect(fetchSpy).toHaveBeenCalledTimes(1) }) }) // --------------------------------------------------------------------------- // 6. handlePaymentAndRetry – maxAttempts exceeded before first try // --------------------------------------------------------------------------- describe('AuthFetch.handlePaymentAndRetry – maxAttempts exceeded pre-check', () => { it('throws immediately when attempts >= maxAttempts', async () => { const authFetch = new AuthFetch(buildWallet()) jest.spyOn(authFetch as any, 'logPaymentAttempt').mockImplementation(() => {}) const exhaustedContext = { satoshisRequired: 10, transactionBase64: Utils.toBase64([1]), derivationPrefix: 'pfx', derivationSuffix: 'sfx', serverIdentityKey: 'srv-key', clientIdentityKey: 'client-key', attempts: 3, maxAttempts: 3, errors: [], requestSummary: { url: 'https://example.com', method: 'GET', headers: {}, bodyType: 'none', bodyByteLength: 0 } } const response = make402Response() await expect( (authFetch as any).handlePaymentAndRetry( 'https://example.com', { paymentContext: exhaustedContext }, response ) ).rejects.toThrow('Paid request to https://example.com failed after 3/3 attempts') }) }) // --------------------------------------------------------------------------- // 7. serializeRequest – header and body branches // --------------------------------------------------------------------------- describe('AuthFetch.serializeRequest (private)', () => { it('serializes a GET request with no body or headers', async () => { const authFetch = new AuthFetch(buildWallet()) const nonce = new Array(32).fill(0) const writer = await (authFetch as any).serializeRequest( 'GET', {}, undefined, new URL('https://example.com/path'), nonce ) expect(writer).toBeDefined() expect(writer.toArray().length).toBeGreaterThan(32) }) it('serializes a POST request with a JSON body', async () => { const authFetch = new AuthFetch(buildWallet()) const nonce = new Array(32).fill(1) const writer = await (authFetch as any).serializeRequest( 'POST', { 'content-type': 'application/json' }, { hello: 'world' }, new URL('https://example.com/api'), nonce ) expect(writer.toArray().length).toBeGreaterThan(32) }) it('serializes a request with search params', async () => { const authFetch = new AuthFetch(buildWallet()) const nonce = new Array(32).fill(2) const writer = await (authFetch as any).serializeRequest( 'GET', {}, undefined, new URL('https://example.com/api?q=hello'), nonce ) expect(writer.toArray().length).toBeGreaterThan(32) }) it('includes x-bsv-* custom headers', async () => { const authFetch = new AuthFetch(buildWallet()) const nonce = new Array(32).fill(3) const writer = await (authFetch as any).serializeRequest( 'GET', { 'x-bsv-custom': 'value123' }, undefined, new URL('https://example.com/'), nonce ) expect(writer.toArray().length).toBeGreaterThan(32) }) it('includes authorization header', async () => { const authFetch = new AuthFetch(buildWallet()) const nonce = new Array(32).fill(4) const writer = await (authFetch as any).serializeRequest( 'GET', { authorization: 'Bearer token123' }, undefined, new URL('https://example.com/'), nonce ) expect(writer.toArray().length).toBeGreaterThan(32) }) it('throws for x-bsv-auth-* headers', async () => { const authFetch = new AuthFetch(buildWallet()) const nonce = new Array(32).fill(5) await expect( (authFetch as any).serializeRequest( 'GET', { 'x-bsv-auth-identity-key': 'spoofed' }, undefined, new URL('https://example.com/'), nonce ) ).rejects.toThrow('No BSV auth headers allowed here!') }) it('throws for unsupported headers', async () => { const authFetch = new AuthFetch(buildWallet()) const nonce = new Array(32).fill(6) await expect( (authFetch as any).serializeRequest( 'GET', { 'accept': 'application/json' }, undefined, new URL('https://example.com/'), nonce ) ).rejects.toThrow('Unsupported header') }) it('normalizes content-type by stripping parameters', async () => { const authFetch = new AuthFetch(buildWallet()) const nonce = new Array(32).fill(7) // Should not throw — content-type is allowed but parameters are stripped const writer = await (authFetch as any).serializeRequest( 'POST', { 'content-type': 'application/json; charset=utf-8' }, '{"x":1}', new URL('https://example.com/api'), nonce ) expect(writer.toArray().length).toBeGreaterThan(32) }) it('defaults POST body to {} when content-type is application/json and body is undefined', async () => { const authFetch = new AuthFetch(buildWallet()) const nonce = new Array(32).fill(8) // Should not throw const writer = await (authFetch as any).serializeRequest( 'POST', { 'content-type': 'application/json' }, undefined, new URL('https://example.com/api'), nonce ) expect(writer.toArray().length).toBeGreaterThan(32) }) it('defaults DELETE body to empty string when no content-type and body is undefined', async () => { const authFetch = new AuthFetch(buildWallet()) const nonce = new Array(32).fill(9) const writer = await (authFetch as any).serializeRequest( 'DELETE', {}, undefined, new URL('https://example.com/resource'), nonce ) expect(writer.toArray().length).toBeGreaterThan(32) }) }) // --------------------------------------------------------------------------- // 8. describeRequestBodyForLogging – all body types // --------------------------------------------------------------------------- describe('AuthFetch.describeRequestBodyForLogging (private)', () => { let authFetch: AuthFetch beforeEach(() => { authFetch = new AuthFetch(buildWallet()) }) it('returns type=none for null body', () => { const result = (authFetch as any).describeRequestBodyForLogging(null) expect(result).toEqual({ type: 'none', byteLength: 0 }) }) it('returns type=none for undefined body', () => { const result = (authFetch as any).describeRequestBodyForLogging(undefined) expect(result).toEqual({ type: 'none', byteLength: 0 }) }) it('returns type=string with correct byteLength', () => { const result = (authFetch as any).describeRequestBodyForLogging('hello') expect(result.type).toBe('string') expect(result.byteLength).toBe(5) }) it('returns type=number[] for number array', () => { const result = (authFetch as any).describeRequestBodyForLogging([1, 2, 3]) expect(result).toEqual({ type: 'number[]', byteLength: 3 }) }) it('returns type=array for non-number array', () => { const result = (authFetch as any).describeRequestBodyForLogging(['a', 'b']) expect(result).toEqual({ type: 'array', byteLength: 2 }) }) it('returns type=ArrayBuffer for ArrayBuffer', () => { const buf = new ArrayBuffer(8) const result = (authFetch as any).describeRequestBodyForLogging(buf) expect(result).toEqual({ type: 'ArrayBuffer', byteLength: 8 }) }) it('returns typed array name for Uint8Array', () => { const arr = new Uint8Array([1, 2, 3, 4]) const result = (authFetch as any).describeRequestBodyForLogging(arr) expect(result.type).toBe('Uint8Array') expect(result.byteLength).toBe(4) }) it('returns type=Blob for Blob', () => { const blob = new Blob(['hello world']) const result = (authFetch as any).describeRequestBodyForLogging(blob) expect(result.type).toBe('Blob') expect(result.byteLength).toBeGreaterThan(0) }) it('returns type=URLSearchParams for URLSearchParams', () => { const params = new URLSearchParams({ key: 'value' }) const result = (authFetch as any).describeRequestBodyForLogging(params) expect(result.type).toBe('URLSearchParams') expect(result.byteLength).toBeGreaterThan(0) }) it('returns type=FormData for FormData', () => { const fd = new FormData() fd.append('field', 'value') const result = (authFetch as any).describeRequestBodyForLogging(fd) expect(result.type).toBe('FormData') expect(result.byteLength).toBe(0) }) it('returns type=object for a plain object', () => { const result = (authFetch as any).describeRequestBodyForLogging({ a: 1 }) expect(result.type).toBe('object') expect(result.byteLength).toBeGreaterThan(0) }) it('returns type=ReadableStream for ReadableStream', () => { const stream = new ReadableStream() const result = (authFetch as any).describeRequestBodyForLogging(stream) expect(result).toEqual({ type: 'ReadableStream', byteLength: 0 }) }) it('falls back to typeof for an unrecognised type', () => { // A Symbol cannot be JSON-stringified, triggering the fallback const sym = Symbol('test') const result = (authFetch as any).describeRequestBodyForLogging(sym) expect(result.type).toBe('symbol') expect(result.byteLength).toBe(0) }) }) // --------------------------------------------------------------------------- // 9. normalizeBodyToNumberArray – edge cases // --------------------------------------------------------------------------- describe('AuthFetch.normalizeBodyToNumberArray (private)', () => { let authFetch: AuthFetch beforeEach(() => { authFetch = new AuthFetch(buildWallet()) }) it('returns [] for null', async () => { const result = await (authFetch as any).normalizeBodyToNumberArray(null) expect(result).toEqual([]) }) it('returns [] for undefined', async () => { const result = await (authFetch as any).normalizeBodyToNumberArray(undefined) expect(result).toEqual([]) }) it('converts a string to a number array', async () => { const result = await (authFetch as any).normalizeBodyToNumberArray('abc') expect(result.length).toBe(3) }) it('converts a number[] to a JSON-encoded number array', async () => { // Arrays are objects, so they hit the typeof === 'object' branch first // and are serialized via JSON.stringify before the number[] guard runs. const input = [1, 2, 3] const result = await (authFetch as any).normalizeBodyToNumberArray(input) // '[1,2,3]' encoded as UTF-8 bytes const expected = Utils.toArray(JSON.stringify(input), 'utf8') expect(result).toEqual(expected) }) it('converts ArrayBuffer to a JSON-encoded number array', async () => { // ArrayBuffer is an object, so it hits the typeof === 'object' branch first. const buf = new Uint8Array([10, 20, 30]).buffer const result = await (authFetch as any).normalizeBodyToNumberArray(buf) // JSON.stringify of an ArrayBuffer produces '{}' const expected = Utils.toArray(JSON.stringify(buf), 'utf8') expect(result).toEqual(expected) }) it('converts Uint8Array to a JSON-encoded number array', async () => { // Uint8Array is an object, so it hits the typeof === 'object' branch first. const arr = new Uint8Array([5, 6, 7]) const result = await (authFetch as any).normalizeBodyToNumberArray(arr) // JSON.stringify of a Uint8Array produces e.g. '{"0":5,"1":6,"2":7}' const expected = Utils.toArray(JSON.stringify(arr), 'utf8') expect(result).toEqual(expected) }) it('converts Blob via JSON.stringify (object branch)', async () => { // Blob is an object — hits the typeof === 'object' branch before the Blob check. const blob = new Blob(['hi']) const result = await (authFetch as any).normalizeBodyToNumberArray(blob) // JSON.stringify(new Blob(...)) → '{}' const expected = Utils.toArray(JSON.stringify(blob), 'utf8') expect(result).toEqual(expected) }) it('converts FormData via JSON.stringify (object branch)', async () => { // FormData is an object — hits the typeof === 'object' branch before the FormData check. const fd = new FormData() fd.append('name', 'alice') const result = await (authFetch as any).normalizeBodyToNumberArray(fd) // JSON.stringify(FormData) → '{}' const expected = Utils.toArray(JSON.stringify(fd), 'utf8') expect(result).toEqual(expected) }) it('converts URLSearchParams via JSON.stringify (object branch)', async () => { // URLSearchParams is an object — hits typeof === 'object' branch first. const params = new URLSearchParams({ q: 'hello' }) const result = await (authFetch as any).normalizeBodyToNumberArray(params) // JSON.stringify(URLSearchParams) → '{}' const expected = Utils.toArray(JSON.stringify(params), 'utf8') expect(result).toEqual(expected) }) it('converts ReadableStream via JSON.stringify (object branch)', async () => { // ReadableStream is an object, so it hits the typeof === 'object' branch first // and is serialized via JSON.stringify (produces '{}') rather than throwing. const stream = new ReadableStream() const result = await (authFetch as any).normalizeBodyToNumberArray(stream) const expected = Utils.toArray(JSON.stringify(stream), 'utf8') expect(result).toEqual(expected) }) it('converts a plain object via JSON.stringify', async () => { const obj = { key: 'value' } const result = await (authFetch as any).normalizeBodyToNumberArray(obj) expect(result.length).toBeGreaterThan(0) }) }) // --------------------------------------------------------------------------- // 10. getMaxPaymentAttempts // --------------------------------------------------------------------------- describe('AuthFetch.getMaxPaymentAttempts (private)', () => { let authFetch: AuthFetch beforeEach(() => { authFetch = new AuthFetch(buildWallet()) }) it('returns 3 by default', () => { expect((authFetch as any).getMaxPaymentAttempts({})).toBe(3) }) it('returns the configured positive integer', () => { expect((authFetch as any).getMaxPaymentAttempts({ paymentRetryAttempts: 5 })).toBe(5) }) it('floors the value for a float', () => { expect((authFetch as any).getMaxPaymentAttempts({ paymentRetryAttempts: 4.9 })).toBe(4) }) it('returns 3 when paymentRetryAttempts is 0', () => { expect((authFetch as any).getMaxPaymentAttempts({ paymentRetryAttempts: 0 })).toBe(3) }) it('returns 3 when paymentRetryAttempts is negative', () => { expect((authFetch as any).getMaxPaymentAttempts({ paymentRetryAttempts: -1 })).toBe(3) }) it('returns 3 when paymentRetryAttempts is a string', () => { expect((authFetch as any).getMaxPaymentAttempts({ paymentRetryAttempts: 'five' as any })).toBe(3) }) }) // --------------------------------------------------------------------------- // 11. getPaymentRetryDelay // --------------------------------------------------------------------------- describe('AuthFetch.getPaymentRetryDelay (private)', () => { let authFetch: AuthFetch beforeEach(() => { authFetch = new AuthFetch(buildWallet()) }) it('returns 250 for attempt 1 (250 * 1)', () => { expect((authFetch as any).getPaymentRetryDelay(1)).toBe(250) }) it('returns 500 for attempt 2', () => { expect((authFetch as any).getPaymentRetryDelay(2)).toBe(500) }) it('caps multiplier at 5 for attempt >= 5', () => { expect((authFetch as any).getPaymentRetryDelay(5)).toBe(1250) expect((authFetch as any).getPaymentRetryDelay(10)).toBe(1250) expect((authFetch as any).getPaymentRetryDelay(100)).toBe(1250) }) }) // --------------------------------------------------------------------------- // 12. wait() // --------------------------------------------------------------------------- describe('AuthFetch.wait (private)', () => { it('resolves immediately for ms <= 0', async () => { const authFetch = new AuthFetch(buildWallet()) const start = Date.now() await (authFetch as any).wait(0) expect(Date.now() - start).toBeLessThan(50) }) it('resolves immediately for negative ms', async () => { const authFetch = new AuthFetch(buildWallet()) const start = Date.now() await (authFetch as any).wait(-100) expect(Date.now() - start).toBeLessThan(50) }) it('uses a timer for positive ms', async () => { jest.useFakeTimers() try { const authFetch = new AuthFetch(buildWallet()) let resolved = false const promise = (authFetch as any).wait(500).then(() => { resolved = true }) expect(resolved).toBe(false) await jest.advanceTimersByTimeAsync(500) await promise expect(resolved).toBe(true) } finally { jest.useRealTimers() } }) }) // --------------------------------------------------------------------------- // 13. isPaymentContextCompatible // --------------------------------------------------------------------------- describe('AuthFetch.isPaymentContextCompatible (private)', () => { let authFetch: AuthFetch beforeEach(() => { authFetch = new AuthFetch(buildWallet()) }) function makeCtx (overrides: Partial = {}): any { return { satoshisRequired: 10, serverIdentityKey: 'srv', derivationPrefix: 'pfx', ...overrides } } it('returns true when all fields match', () => { const ctx = makeCtx() expect((authFetch as any).isPaymentContextCompatible(ctx, 10, 'srv', 'pfx')).toBe(true) }) it('returns false when satoshis differ', () => { const ctx = makeCtx() expect((authFetch as any).isPaymentContextCompatible(ctx, 20, 'srv', 'pfx')).toBe(false) }) it('returns false when serverIdentityKey differs', () => { const ctx = makeCtx() expect((authFetch as any).isPaymentContextCompatible(ctx, 10, 'other', 'pfx')).toBe(false) }) it('returns false when derivationPrefix differs', () => { const ctx = makeCtx() expect((authFetch as any).isPaymentContextCompatible(ctx, 10, 'srv', 'other')).toBe(false) }) }) // --------------------------------------------------------------------------- // 14. consumeReceivedCertificates // --------------------------------------------------------------------------- describe('AuthFetch.consumeReceivedCertificates', () => { it('returns all received certs and empties the buffer', () => { const authFetch = new AuthFetch(buildWallet()) ;(authFetch as any).certificatesReceived.push( { serialNumber: 'cert1' }, { serialNumber: 'cert2' } ) const certs = authFetch.consumeReceivedCertificates() expect(certs).toHaveLength(2) expect(certs[0]).toMatchObject({ serialNumber: 'cert1' }) // Buffer should now be empty const second = authFetch.consumeReceivedCertificates() expect(second).toHaveLength(0) }) it('returns an empty array when no certs have been received', () => { const authFetch = new AuthFetch(buildWallet()) expect(authFetch.consumeReceivedCertificates()).toEqual([]) }) }) // --------------------------------------------------------------------------- // 15. sendCertificateRequest – creates a new peer when none exists // --------------------------------------------------------------------------- describe('AuthFetch.sendCertificateRequest – new peer creation', () => { it('creates a new Peer transport when no peer exists for the base URL', async () => { const authFetch = new AuthFetch(buildWallet()) // Verify there is no existing peer expect((authFetch as any).peers['https://new-server.com']).toBeUndefined() const fakeCerts = [{ serialNumber: 'abc' }] let capturedListener: ((senderKey: string, certs: any[]) => void) | undefined const peerProto = { listenForCertificatesReceived: jest.fn((cb: any) => { capturedListener = cb return 99 }), stopListeningForCertificatesReceived: jest.fn(), requestCertificates: jest.fn(async () => { capturedListener?.('srv-key', fakeCerts as any) }) } // Intercept Peer constructor by injecting peer directly after fetch call starts const origFetch = authFetch.sendCertificateRequest.bind(authFetch) jest.spyOn(authFetch as any, 'sendCertificateRequest').mockImplementationOnce( async (url: string, certs: any) => { // Manually inject our stub peer so Peer constructor is bypassed ;(authFetch as any).peers['https://new-server.com'] = { peer: peerProto, pendingCertificateRequests: [] } return origFetch(url, certs) } ) const result = await authFetch.sendCertificateRequest( 'https://new-server.com/path', { certifiers: [], types: {} } as any ) expect(result).toHaveLength(1) }) }) // --------------------------------------------------------------------------- // 16. logPaymentAttempt – all levels // --------------------------------------------------------------------------- describe('AuthFetch.logPaymentAttempt (private)', () => { let authFetch: AuthFetch beforeEach(() => { authFetch = new AuthFetch(buildWallet()) }) it('calls console.error for level=error', () => { const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) ;(authFetch as any).logPaymentAttempt('error', 'test error', { a: 1 }) expect(spy).toHaveBeenCalledWith('[AuthFetch][Payment] test error', { a: 1 }) }) it('calls console.warn for level=warn', () => { const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}) ;(authFetch as any).logPaymentAttempt('warn', 'test warn', { b: 2 }) expect(spy).toHaveBeenCalledWith('[AuthFetch][Payment] test warn', { b: 2 }) }) it('calls console.info for level=info when available', () => { const spy = jest.spyOn(console, 'info').mockImplementation(() => {}) ;(authFetch as any).logPaymentAttempt('info', 'test info', { c: 3 }) expect(spy).toHaveBeenCalledWith('[AuthFetch][Payment] test info', { c: 3 }) }) it('falls back to console.log for level=info when console.info is not a function', () => { const originalInfo = console.info ;(console as any).info = undefined const spy = jest.spyOn(console, 'log').mockImplementation(() => {}) try { ;(authFetch as any).logPaymentAttempt('info', 'fallback log', {}) expect(spy).toHaveBeenCalledWith('[AuthFetch][Payment] fallback log', {}) } finally { console.info = originalInfo } }) }) // --------------------------------------------------------------------------- // 17. createPaymentErrorEntry // --------------------------------------------------------------------------- describe('AuthFetch.createPaymentErrorEntry (private)', () => { let authFetch: AuthFetch beforeEach(() => { authFetch = new AuthFetch(buildWallet()) }) it('extracts message and stack from an Error instance', () => { const err = new Error('something went wrong') const entry = (authFetch as any).createPaymentErrorEntry(2, err) expect(entry.attempt).toBe(2) expect(entry.message).toBe('something went wrong') expect(typeof entry.stack).toBe('string') expect(typeof entry.timestamp).toBe('string') }) it('converts non-Error to string message', () => { const entry = (authFetch as any).createPaymentErrorEntry(1, 'just a string error') expect(entry.message).toBe('just a string error') expect(entry.stack).toBeUndefined() }) it('converts numeric error to string message', () => { const entry = (authFetch as any).createPaymentErrorEntry(1, 42) expect(entry.message).toBe('42') }) }) // --------------------------------------------------------------------------- // 18. buildPaymentFailureError // --------------------------------------------------------------------------- describe('AuthFetch.buildPaymentFailureError (private)', () => { let authFetch: AuthFetch beforeEach(() => { authFetch = new AuthFetch(buildWallet()) }) function makeContext (): any { return { satoshisRequired: 10, transactionBase64: 'tx-base64', derivationPrefix: 'pfx', derivationSuffix: 'sfx', serverIdentityKey: 'srv', clientIdentityKey: 'cli', attempts: 3, maxAttempts: 3, errors: [], requestSummary: { url: 'https://ex.com', method: 'GET', headers: {}, bodyType: 'none', bodyByteLength: 0 } } } it('creates an Error with a descriptive message', () => { const err = (authFetch as any).buildPaymentFailureError( 'https://example.com/pay', makeContext(), new Error('last error') ) expect(err).toBeInstanceOf(Error) expect(err.message).toContain('https://example.com/pay') expect(err.message).toContain('3/3') expect(err.message).toContain('10 satoshis') }) it('attaches details to the error', () => { const err = (authFetch as any).buildPaymentFailureError( 'https://example.com/pay', makeContext(), new Error('x') ) expect(err.details).toBeDefined() expect(err.details.payment.satoshis).toBe(10) expect(err.details.attempts.used).toBe(3) }) it('sets cause when lastError is an Error', () => { const cause = new Error('root cause') const err = (authFetch as any).buildPaymentFailureError( 'https://example.com/pay', makeContext(), cause ) expect(err.cause).toBe(cause) }) it('does not set cause when lastError is a string', () => { const err = (authFetch as any).buildPaymentFailureError( 'https://example.com/pay', makeContext(), 'string error' ) expect(err.cause).toBeUndefined() }) }) // --------------------------------------------------------------------------- // 19. buildPaymentRequestSummary // --------------------------------------------------------------------------- describe('AuthFetch.buildPaymentRequestSummary (private)', () => { it('builds a summary with correct fields', () => { const authFetch = new AuthFetch(buildWallet()) const summary = (authFetch as any).buildPaymentRequestSummary( 'https://example.com/resource', { method: 'post', headers: { 'X-Custom': 'value' }, body: 'hello' } ) expect(summary.url).toBe('https://example.com/resource') expect(summary.method).toBe('POST') expect(summary.headers).toMatchObject({ 'X-Custom': 'value' }) expect(summary.bodyType).toBe('string') expect(summary.bodyByteLength).toBe(5) }) it('defaults method to GET when not provided', () => { const authFetch = new AuthFetch(buildWallet()) const summary = (authFetch as any).buildPaymentRequestSummary('https://example.com', {}) expect(summary.method).toBe('GET') }) })