From 3fd9f684937864b1e99a1aa38cdea660cc9dba65 Mon Sep 17 00:00:00 2001 From: Toshit Chawda Date: Wed, 29 Jul 2026 16:02:51 -0700 Subject: [PATCH] test: cover the epoxy-based networking client Replaces the coverage lost when the wisp implementation was removed: PSocket.test.js and PWispHandler.test.js were bound to PWispHandler, parsers.js, and wispInfo, none of which survive the epoxy rewrite. PSocket.test.js - connect/retry, inbound data, write normalisation, close, and the TLS event remapping. Driven through real ReadableStream/WritableStream pairs so reader locking, cancel, and abort behave as they do in the browser. index.test.js - relay-token exchange (auth header, 401 re-auth and retry, malformed responses) and the epoxy client cache (keying, refresh, in-flight sharing). requests.test.js - pFetch delegation, cache invalidation, and the api call logger's request description. epoxy.test.js - the hand-packed wisp password extension payload. The wasm runtime loader is left to integration coverage; it needs a network fetch and a browser. createPuterPasswordBuilder is exported so the byte layout can be tested against an injected fake runtime. One skipped test records a pre-existing defect: on a failed write, #readLoop's normal-termination path races #closeStreams and emits close(false) after an error. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/modules/networking/PSocket.test.js | 445 ++++++++++++++++++ src/puter-js/src/modules/networking/epoxy.js | 4 +- .../src/modules/networking/epoxy.test.js | 79 ++++ .../src/modules/networking/index.test.js | 272 +++++++++++ .../src/modules/networking/requests.test.js | 164 +++++++ 5 files changed, 963 insertions(+), 1 deletion(-) create mode 100644 src/puter-js/src/modules/networking/PSocket.test.js create mode 100644 src/puter-js/src/modules/networking/epoxy.test.js create mode 100644 src/puter-js/src/modules/networking/index.test.js create mode 100644 src/puter-js/src/modules/networking/requests.test.js diff --git a/src/puter-js/src/modules/networking/PSocket.test.js b/src/puter-js/src/modules/networking/PSocket.test.js new file mode 100644 index 000000000..11ed1c149 --- /dev/null +++ b/src/puter-js/src/modules/networking/PSocket.test.js @@ -0,0 +1,445 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// PSocket's only collaborator is the epoxy client, so that module is stubbed: +// no wasm runtime, no relay, no websocket. Stubbing it also breaks the +// PSocket <-> index.js import cycle for these tests. +const mockGetEpoxyClient = vi.fn(); +const mockClearEpoxyClientCache = vi.fn(); +vi.mock('./index.js', () => ({ + getEpoxyClient: (...args) => mockGetEpoxyClient(...args), + clearEpoxyClientCache: (...args) => mockClearEpoxyClientCache(...args), +})); + +const { PSocket, PTLSSocket } = await import('./PSocket.js'); + +// Socket events land a few microtasks after the call that triggers them, so +// assertions retry rather than guess a tick count. The default 50ms poll would +// dominate the runtime of a suite this size; these waits resolve almost +// immediately. +const until = assertion => vi.waitFor(assertion, { interval: 1, timeout: 1000 }); + +// A duplex pair shaped like what `EpoxyClient.connect` returns: a +// ReadableStream of inbound bytes plus a WritableStream of outbound ones. +// These are the platform's real stream implementations, so reader/writer +// locking, cancel, and abort behave as they do in the browser. +function makeStream ({ failWrite } = {}) { + let readController; + const read = new ReadableStream({ + start (controller) { + readController = controller; + }, + }); + + const written = []; + const write = new WritableStream({ + write (chunk) { + if ( failWrite ) { + throw failWrite; + } + written.push(chunk); + }, + }); + + return { + read, + write, + written, + push: bytes => readController.enqueue(bytes), + endRead: () => readController.close(), + failRead: error => readController.error(error), + }; +} + +function makeClient (stream) { + return { + connect: vi.fn(async () => stream), + connectTls: vi.fn(async () => stream), + }; +} + +// A promise whose settlement the test controls, for pausing mid-connect. +function deferred () { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +// Attaches spies for every event a caller can observe. `error` is never +// tls-prefixed, unlike open/data/close. +function listen (socket) { + const spies = { + open: vi.fn(), + data: vi.fn(), + close: vi.fn(), + error: vi.fn(), + }; + for ( const event of Object.keys(spies) ) { + socket.on(event, spies[event]); + } + return spies; +} + +// Opens a socket and waits until it is ready to write. +async function connected (stream = makeStream()) { + const client = makeClient(stream); + mockGetEpoxyClient.mockResolvedValue(client); + + const socket = new PSocket('example.com', 80); + const events = listen(socket); + await until(() => expect(events.open).toHaveBeenCalledTimes(1)); + + return { socket, events, client, stream }; +} + +beforeEach(() => { + mockGetEpoxyClient.mockReset(); + mockClearEpoxyClientCache.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('PSocket connect', () => { + it('opens a stream through the epoxy client', async () => { + const { client, events } = await connected(); + + expect(client.connect).toHaveBeenCalledWith('example.com', 80); + expect(client.connectTls).not.toHaveBeenCalled(); + expect(mockGetEpoxyClient).toHaveBeenCalledWith({ refresh: false }); + expect(events.error).not.toHaveBeenCalled(); + }); + + it('coerces a string port to a number', async () => { + const client = makeClient(makeStream()); + mockGetEpoxyClient.mockResolvedValue(client); + + const socket = new PSocket('example.com', '443'); + const events = listen(socket); + await until(() => expect(events.open).toHaveBeenCalled()); + + expect(client.connect).toHaveBeenCalledWith('example.com', 443); + }); + + it('retries with a refreshed client when the first attempt fails', async () => { + const client = makeClient(makeStream()); + mockGetEpoxyClient + .mockRejectedValueOnce(new Error('stale client')) + .mockResolvedValueOnce(client); + + const socket = new PSocket('example.com', 80); + const events = listen(socket); + await until(() => expect(events.open).toHaveBeenCalledTimes(1)); + + expect(mockGetEpoxyClient).toHaveBeenNthCalledWith(1, { refresh: false }); + expect(mockGetEpoxyClient).toHaveBeenNthCalledWith(2, { refresh: true }); + expect(events.error).not.toHaveBeenCalled(); + }); + + it('emits an error and closes when both attempts fail', async () => { + mockGetEpoxyClient.mockRejectedValue(new Error('relay down')); + + const socket = new PSocket('example.com', 80); + const events = listen(socket); + await until(() => expect(events.close).toHaveBeenCalled()); + + expect(events.open).not.toHaveBeenCalled(); + expect(events.error).toHaveBeenCalledTimes(1); + expect(events.close).toHaveBeenCalledWith(true); + expect(mockClearEpoxyClientCache).toHaveBeenCalled(); + }); + + // The types declare the handler as `(error: Error) => void` and document the + // reason as `error.message`, so a rejection has to arrive wrapped. + it('reports failures as Error instances', async () => { + mockGetEpoxyClient.mockRejectedValue(new Error('relay down')); + + const socket = new PSocket('example.com', 80); + const events = listen(socket); + await until(() => expect(events.error).toHaveBeenCalled()); + + const [reason] = events.error.mock.calls[0]; + expect(reason).toBeInstanceOf(Error); + expect(reason.message).toBe('relay down'); + }); + + it('wraps a non-Error rejection reason', async () => { + mockGetEpoxyClient.mockRejectedValue('just a string'); + + const socket = new PSocket('example.com', 80); + const events = listen(socket); + await until(() => expect(events.error).toHaveBeenCalled()); + + const [reason] = events.error.mock.calls[0]; + expect(reason).toBeInstanceOf(Error); + expect(reason.message).toBe('just a string'); + }); +}); + +describe('PSocket inbound data', () => { + it('emits each chunk the remote sends', async () => { + const { events, stream } = await connected(); + + stream.push(new Uint8Array([1, 2, 3])); + await until(() => expect(events.data).toHaveBeenCalledTimes(1)); + + expect(Array.from(events.data.mock.calls[0][0])).toEqual([1, 2, 3]); + }); + + it('emits chunks in order', async () => { + const { events, stream } = await connected(); + + stream.push(new Uint8Array([1])); + stream.push(new Uint8Array([2])); + await until(() => expect(events.data).toHaveBeenCalledTimes(2)); + + expect(events.data.mock.calls.map(([chunk]) => Array.from(chunk))) + .toEqual([[1], [2]]); + }); + + it('closes without an error flag when the remote ends the stream', async () => { + const { events, stream } = await connected(); + + stream.endRead(); + await until(() => expect(events.close).toHaveBeenCalled()); + + expect(events.close).toHaveBeenCalledWith(false); + expect(events.error).not.toHaveBeenCalled(); + }); + + it('emits an error and drops the cached client when the read fails', async () => { + const { events, stream } = await connected(); + + stream.failRead(new Error('connection reset')); + await until(() => expect(events.error).toHaveBeenCalled()); + + expect(events.error.mock.calls[0][0].message).toBe('connection reset'); + expect(mockClearEpoxyClientCache).toHaveBeenCalled(); + await until(() => expect(events.close).toHaveBeenCalledWith(true)); + }); +}); + +describe('PSocket write', () => { + it('writes a typed array', async () => { + const { socket, stream } = await connected(); + + socket.write(new Uint8Array([1, 2, 3])); + await until(() => expect(stream.written).toHaveLength(1)); + + expect(Array.from(stream.written[0])).toEqual([1, 2, 3]); + }); + + it('writes an ArrayBuffer', async () => { + const { socket, stream } = await connected(); + + socket.write(new Uint8Array([4, 5, 6]).buffer); + await until(() => expect(stream.written).toHaveLength(1)); + + expect(Array.from(stream.written[0])).toEqual([4, 5, 6]); + }); + + // A view can cover part of a larger buffer; only its own bytes may be sent. + it('writes only the bytes a partial view covers', async () => { + const { socket, stream } = await connected(); + const backing = new Uint8Array([9, 1, 2, 3, 9]).buffer; + + socket.write(new Uint8Array(backing, 1, 3)); + await until(() => expect(stream.written).toHaveLength(1)); + + expect(Array.from(stream.written[0])).toEqual([1, 2, 3]); + }); + + it('encodes a string as utf-8', async () => { + const { socket, stream } = await connected(); + + socket.write('hé'); + await until(() => expect(stream.written).toHaveLength(1)); + + expect(Array.from(stream.written[0])).toEqual([104, 195, 169]); + }); + + it('throws on an unsupported data type', async () => { + const { socket } = await connected(); + + expect(() => socket.write(42)).toThrow(/Invalid data type/); + }); + + it('invokes the callback once the write lands', async () => { + const { socket } = await connected(); + const callback = vi.fn(); + + socket.write('hi', callback); + await until(() => expect(callback).toHaveBeenCalledTimes(1)); + }); + + it('queues writes issued before the socket opens and flushes them in order', async () => { + const stream = makeStream(); + const gate = deferred(); + mockGetEpoxyClient.mockReturnValue(gate.promise); + + const socket = new PSocket('example.com', 80); + const events = listen(socket); + + // Still connecting, so neither write can reach the stream yet. + socket.write('first'); + socket.write('second'); + expect(stream.written).toHaveLength(0); + + gate.resolve(makeClient(stream)); + await until(() => expect(events.open).toHaveBeenCalled()); + await until(() => expect(stream.written).toHaveLength(2)); + + const decoder = new TextDecoder(); + expect(stream.written.map(chunk => decoder.decode(chunk))) + .toEqual(['first', 'second']); + }); + + it('throws when writing to a closed socket', async () => { + const { socket, events } = await connected(); + + socket.close(); + await until(() => expect(events.close).toHaveBeenCalled()); + + expect(() => socket.write('late')).toThrow(/already closed/); + }); + + it('emits an error and drops the cached client when a write fails', async () => { + const stream = makeStream({ failWrite: new Error('write failed') }); + const { socket, events } = await connected(stream); + + socket.write('doomed'); + await until(() => expect(events.error).toHaveBeenCalled()); + + expect(events.error.mock.calls[0][0].message).toBe('write failed'); + expect(mockClearEpoxyClientCache).toHaveBeenCalled(); + }); + + // KNOWN BUG -- unskip once #readLoop stops racing the error path. + // + // A failed write sets #closing and hands the close event to #closeStreams, + // which must await reader.cancel() and writer.close() before emitting + // close(true). That cancel resolves #readLoop's pending read() with + // {done: true}, so the loop breaks and reaches its own #emitClose(false) + // first -- #closed is already set by the time #closeStreams gets there, so + // callers are told the socket shut down cleanly after an error. + // A read failure is unaffected: that path throws into #readLoop's catch, + // which never calls #emitClose(false). + it.skip('closes with the error flag set when a write fails', async () => { + const stream = makeStream({ failWrite: new Error('write failed') }); + const { socket, events } = await connected(stream); + + socket.write('doomed'); + await until(() => expect(events.close).toHaveBeenCalled()); + + expect(events.close).toHaveBeenCalledWith(true); + }); +}); + +describe('PSocket close', () => { + it('emits close exactly once, even when called repeatedly', async () => { + const { socket, events } = await connected(); + + socket.close(); + socket.close(); + await until(() => expect(events.close).toHaveBeenCalled()); + + expect(events.close).toHaveBeenCalledTimes(1); + expect(events.close).toHaveBeenCalledWith(false); + expect(events.error).not.toHaveBeenCalled(); + }); + + // Closing while the relay handshake is still in flight must not leave the + // freshly opened stream dangling, and must not surface as an open socket. + it('tears down a stream that arrives after close, without emitting open', async () => { + const stream = makeStream(); + const cancelSpy = vi.spyOn(stream.read, 'cancel'); + const abortSpy = vi.spyOn(stream.write, 'abort'); + const gate = deferred(); + mockGetEpoxyClient.mockReturnValue(gate.promise); + + const socket = new PSocket('example.com', 80); + const events = listen(socket); + + socket.close(); + gate.resolve(makeClient(stream)); + + await until(() => expect(cancelSpy).toHaveBeenCalled()); + expect(abortSpy).toHaveBeenCalled(); + expect(events.open).not.toHaveBeenCalled(); + }); + + it('stops emitting data after close', async () => { + const { socket, events, stream } = await connected(); + + socket.close(); + await until(() => expect(events.close).toHaveBeenCalled()); + expect(() => stream.push(new Uint8Array([1]))).toThrow(); + + expect(events.data).not.toHaveBeenCalled(); + }); +}); + +describe('PTLSSocket', () => { + it('opens a TLS stream and reports tls-prefixed events through on()', async () => { + const stream = makeStream(); + const client = makeClient(stream); + mockGetEpoxyClient.mockResolvedValue(client); + + const socket = new PTLSSocket('example.com', 443); + const events = listen(socket); + await until(() => expect(events.open).toHaveBeenCalledTimes(1)); + + expect(client.connectTls).toHaveBeenCalledWith('example.com', 443); + expect(client.connect).not.toHaveBeenCalled(); + + stream.push(new Uint8Array([7])); + await until(() => expect(events.data).toHaveBeenCalledTimes(1)); + expect(Array.from(events.data.mock.calls[0][0])).toEqual([7]); + + stream.endRead(); + await until(() => expect(events.close).toHaveBeenCalledWith(false)); + }); + + // `on('open')` is sugar that remaps onto the tls-prefixed name; listening + // for the prefixed name directly has to keep working too. + it('also accepts the tls-prefixed event names directly', async () => { + const stream = makeStream(); + mockGetEpoxyClient.mockResolvedValue(makeClient(stream)); + + const socket = new PTLSSocket('example.com', 443); + const onTlsOpen = vi.fn(); + const onTlsData = vi.fn(); + socket.on('tlsopen', onTlsOpen); + socket.on('tlsdata', onTlsData); + + await until(() => expect(onTlsOpen).toHaveBeenCalledTimes(1)); + + stream.push(new Uint8Array([8])); + await until(() => expect(onTlsData).toHaveBeenCalledTimes(1)); + }); + + it('reports errors on the unprefixed error event', async () => { + mockGetEpoxyClient.mockRejectedValue(new Error('tls handshake failed')); + + const socket = new PTLSSocket('example.com', 443); + const events = listen(socket); + await until(() => expect(events.error).toHaveBeenCalled()); + + expect(events.error.mock.calls[0][0].message).toBe('tls handshake failed'); + }); + + it('routes addListener through the same remapping as on()', async () => { + const stream = makeStream(); + mockGetEpoxyClient.mockResolvedValue(makeClient(stream)); + + const socket = new PTLSSocket('example.com', 443); + const onOpen = vi.fn(); + socket.addListener('open', onOpen); + + await until(() => expect(onOpen).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/src/puter-js/src/modules/networking/epoxy.js b/src/puter-js/src/modules/networking/epoxy.js index d8860b142..bb9897d81 100644 --- a/src/puter-js/src/modules/networking/epoxy.js +++ b/src/puter-js/src/modules/networking/epoxy.js @@ -29,7 +29,9 @@ async function getEpoxyRuntime () { } } -function createPuterPasswordBuilder (runtime, wispToken) { +// Exported for tests: the wisp password extension's byte layout is hand-packed, +// and `runtime` is injectable, so it can be exercised without the wasm bundle. +export function createPuterPasswordBuilder (runtime, wispToken) { class PuterPasswordExt extends runtime.JsProtocolExtension { constructor (required, toSend) { super(0x02, [], []); diff --git a/src/puter-js/src/modules/networking/epoxy.test.js b/src/puter-js/src/modules/networking/epoxy.test.js new file mode 100644 index 000000000..0f4673257 --- /dev/null +++ b/src/puter-js/src/modules/networking/epoxy.test.js @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { createPuterPasswordBuilder } from './epoxy.js'; + +// Stand-ins for the extension base classes the epoxy wasm bundle exports. +// Loading the real runtime needs a network fetch plus a wasm instantiation, so +// only the hand-packed byte layout of our subclasses is exercised here. +function makeRuntime () { + return { + JsProtocolExtension: class { + constructor (id, ...rest) { + this.id = id; + this.rest = rest; + } + }, + JsProtocolExtensionBuilder: class { + constructor (id) { + this.id = id; + } + }, + }; +} + +const PUTER_PASSWORD_EXT_ID = 0x02; + +const build = (token = 'wisp-token') => + createPuterPasswordBuilder(makeRuntime(), token); + +describe('puter password extension', () => { + it('registers under the puter password extension id', () => { + expect(build().id).toBe(PUTER_PASSWORD_EXT_ID); + expect(build().buildToExtension().id).toBe(PUTER_PASSWORD_EXT_ID); + }); + + // Wire format: u8 username length, u16-LE password length, then the + // username and password bytes. Puter sends an empty username and the + // relay token as the password. + it('packs an empty username and the token as the password', () => { + const encoded = build('abc').buildToExtension().encode(); + + expect(Array.from(encoded)).toEqual([ + 0, // username length + 3, 0, // password length, little-endian + 97, 98, 99, // "abc" + ]); + }); + + it('encodes the password length little-endian across the u16 boundary', () => { + const token = 'x'.repeat(300); + + const encoded = build(token).buildToExtension().encode(); + + expect(encoded).toHaveLength(3 + 300); + expect(encoded[0]).toBe(0); + // 300 == 0x012c, so the low byte leads. + expect(encoded[1]).toBe(0x2c); + expect(encoded[2]).toBe(0x01); + }); + + it('encodes a multi-byte token by its utf-8 length, not its character count', () => { + // 'é' is two bytes in utf-8, so a 2-character token is 4 bytes. + const encoded = build('éé').buildToExtension().encode(); + + expect(encoded[1]).toBe(4); + expect(Array.from(encoded.slice(3))).toEqual([195, 169, 195, 169]); + }); + + it('sends nothing for an extension parsed off the wire', () => { + // buildFromBytes has no payload to send; only buildToExtension does. + const parsed = build().buildFromBytes(new Uint8Array([1])); + + expect(parsed.encode()).toHaveLength(0); + }); + + it('marks the extension required when the peer flags it', () => { + expect(build().buildFromBytes(new Uint8Array([1])).required).toBe(true); + expect(build().buildFromBytes(new Uint8Array([2])).required).toBe(true); + expect(build().buildFromBytes(new Uint8Array([0])).required).toBe(false); + }); +}); diff --git a/src/puter-js/src/modules/networking/index.test.js b/src/puter-js/src/modules/networking/index.test.js new file mode 100644 index 000000000..5a3ae265f --- /dev/null +++ b/src/puter-js/src/modules/networking/index.test.js @@ -0,0 +1,272 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Only the relay-token exchange and the client cache are under test, so the +// wasm bundle is stubbed out: initEpoxy just hands back a marker object. +const mockInitEpoxy = vi.fn(); +vi.mock('./epoxy.js', () => ({ + initEpoxy: (...args) => mockInitEpoxy(...args), +})); + +const { + clearEpoxyClientCache, + generateWispV1URL, + getEpoxyClient, + getWispCredentials, + netAPI, +} = await import('./index.js'); + +const CREDENTIALS = { token: 'wisp-token', server: 'wss://relay.test' }; + +function relayResponse ({ status = 200, body = CREDENTIALS } = {}) { + return { + ok: status >= 200 && status < 300, + status, + statusText: String(status), + json: async () => body, + }; +} + +// A promise whose settlement the test controls, for overlapping callers. +function deferred () { + let resolve; + const promise = new Promise(res => { + resolve = res; + }); + return { promise, resolve }; +} + +const origPuter = globalThis.puter; +const origFetch = globalThis.fetch; + +let mockFetch; + +beforeEach(() => { + clearEpoxyClientCache(); + + mockInitEpoxy.mockReset() + .mockImplementation(async () => ({ client: 'epoxy' })); + + mockFetch = vi.fn(async () => relayResponse()); + globalThis.fetch = mockFetch; + + globalThis.puter = { + APIOrigin: 'https://api.test', + authToken: 'tok', + // Production clears the stored token, which is what makes the code + // under test prompt for sign-in again on the retry. + resetAuthToken: vi.fn(() => { + globalThis.puter.authToken = null; + }), + ui: { authenticateWithPuter: vi.fn(async () => {}) }, + }; +}); + +afterEach(() => { + clearEpoxyClientCache(); + globalThis.puter = origPuter; + globalThis.fetch = origFetch; +}); + +describe('getWispCredentials', () => { + it('posts to the relay-token endpoint with the bearer token', async () => { + const credentials = await getWispCredentials(); + + expect(credentials).toEqual({ + wispToken: CREDENTIALS.token, + wispServer: CREDENTIALS.server, + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('https://api.test/wisp/relay-token/create'); + expect(init.method).toBe('POST'); + expect(init.headers.Authorization).toBe('Bearer tok'); + expect(init.headers['Content-Type']).toBe('application/json'); + }); + + it('throws when the puter runtime is not up yet', async () => { + globalThis.puter = undefined; + + await expect(getWispCredentials()).rejects.toThrow(/not initialized/); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('prompts for sign-in before requesting a token when there is none', async () => { + globalThis.puter.authToken = undefined; + + await getWispCredentials(); + + expect(globalThis.puter.ui.authenticateWithPuter).toHaveBeenCalledTimes(1); + }); + + it('sends the token the sign-in prompt just established', async () => { + globalThis.puter.authToken = undefined; + globalThis.puter.ui.authenticateWithPuter.mockImplementation(async () => { + globalThis.puter.authToken = 'fresh-tok'; + }); + + await getWispCredentials(); + + const [, init] = mockFetch.mock.calls[0]; + expect(init.headers.Authorization).toBe('Bearer fresh-tok'); + }); + + it('omits the auth header entirely when no token could be obtained', async () => { + globalThis.puter.authToken = undefined; + + await getWispCredentials(); + + const [, init] = mockFetch.mock.calls[0]; + expect(init.headers).not.toHaveProperty('Authorization'); + }); + + it('discards a rejected token, re-authenticates, and retries once', async () => { + mockFetch + .mockImplementationOnce(async () => relayResponse({ status: 401 })) + .mockImplementationOnce(async () => relayResponse()); + globalThis.puter.ui.authenticateWithPuter.mockImplementation(async () => { + globalThis.puter.authToken = 'fresh-tok'; + }); + + const credentials = await getWispCredentials(); + + expect(credentials.wispToken).toBe(CREDENTIALS.token); + expect(globalThis.puter.resetAuthToken).toHaveBeenCalledTimes(1); + expect(globalThis.puter.ui.authenticateWithPuter).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledTimes(2); + // The retry has to carry the newly minted token, not the rejected one. + const [, retryInit] = mockFetch.mock.calls[1]; + expect(retryInit.headers.Authorization).toBe('Bearer fresh-tok'); + }); + + // The retry passes retryAuth=false, so a second 401 must surface rather + // than recurse into an endless re-auth loop. + it('gives up after a second 401 instead of looping', async () => { + mockFetch.mockImplementation(async () => relayResponse({ status: 401 })); + + await expect(getWispCredentials()).rejects.toThrow(/HTTP 401/); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('reports the status when the endpoint fails outright', async () => { + mockFetch.mockImplementation(async () => relayResponse({ status: 500 })); + + await expect(getWispCredentials()).rejects.toThrow(/HTTP 500/); + // 500 is not a re-auth case, so there is no retry. + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['an empty body', {}], + ['a missing server', { token: 'only-token' }], + ['a missing token', { server: 'wss://relay.test' }], + ])('rejects %s from the relay-token endpoint', async (_label, body) => { + mockFetch.mockImplementation(async () => relayResponse({ body })); + + await expect(getWispCredentials()).rejects.toThrow(/invalid response/); + }); +}); + +describe('generateWispV1URL', () => { + it('joins the relay server and token into a wisp url', async () => { + await expect(generateWispV1URL()).resolves.toBe('wss://relay.test/wisp-token/'); + }); + + it('is reachable through the public net API', async () => { + await expect(netAPI.generateWispV1URL()).resolves.toBe('wss://relay.test/wisp-token/'); + }); +}); + +describe('getEpoxyClient', () => { + it('builds a client from freshly minted credentials', async () => { + const client = await getEpoxyClient(); + + expect(client).toEqual({ client: 'epoxy' }); + expect(mockInitEpoxy).toHaveBeenCalledWith({ + wispToken: CREDENTIALS.token, + wispServer: CREDENTIALS.server, + }); + }); + + it('reuses the cached client for the same origin and token', async () => { + const first = await getEpoxyClient(); + const second = await getEpoxyClient(); + + expect(second).toBe(first); + expect(mockInitEpoxy).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('rebuilds the client when the auth token changes', async () => { + await getEpoxyClient(); + globalThis.puter.authToken = 'different-tok'; + await getEpoxyClient(); + + expect(mockInitEpoxy).toHaveBeenCalledTimes(2); + }); + + it('rebuilds the client when the API origin changes', async () => { + await getEpoxyClient(); + globalThis.puter.APIOrigin = 'https://other.test'; + await getEpoxyClient(); + + expect(mockInitEpoxy).toHaveBeenCalledTimes(2); + }); + + it('rebuilds the client when a refresh is requested', async () => { + await getEpoxyClient(); + await getEpoxyClient({ refresh: true }); + + expect(mockInitEpoxy).toHaveBeenCalledTimes(2); + }); + + it('shares one in-flight init between concurrent callers', async () => { + const gate = deferred(); + mockInitEpoxy.mockImplementation(() => gate.promise); + + const both = Promise.all([getEpoxyClient(), getEpoxyClient()]); + gate.resolve({ client: 'epoxy' }); + const [first, second] = await both; + + expect(first).toBe(second); + expect(mockInitEpoxy).toHaveBeenCalledTimes(1); + }); + + it('drops the cache after clearEpoxyClientCache', async () => { + await getEpoxyClient(); + clearEpoxyClientCache(); + await getEpoxyClient(); + + expect(mockInitEpoxy).toHaveBeenCalledTimes(2); + }); + + // A failed init must not be cached, otherwise every later socket would keep + // resolving the same broken attempt. + it('does not cache a failed init, so the next caller retries', async () => { + mockInitEpoxy.mockRejectedValueOnce(new Error('wasm unavailable')); + + await getEpoxyClient(); + const retried = await getEpoxyClient(); + + expect(mockInitEpoxy).toHaveBeenCalledTimes(2); + expect(retried).toEqual({ client: 'epoxy' }); + }); + + // Credential failures are swallowed the same way an init failure is. + it('does not cache a failed credential fetch', async () => { + mockFetch.mockImplementationOnce(async () => relayResponse({ status: 500 })); + + await getEpoxyClient(); + const retried = await getEpoxyClient(); + + expect(retried).toEqual({ client: 'epoxy' }); + }); +}); + +describe('netAPI surface', () => { + it('exposes the socket constructors and fetch the docs promise', () => { + expect(typeof netAPI.Socket).toBe('function'); + expect(typeof netAPI.tls.TLSSocket).toBe('function'); + expect(typeof netAPI.fetch).toBe('function'); + expect(typeof netAPI.generateWispV1URL).toBe('function'); + }); +}); diff --git a/src/puter-js/src/modules/networking/requests.test.js b/src/puter-js/src/modules/networking/requests.test.js new file mode 100644 index 000000000..111ea9ccb --- /dev/null +++ b/src/puter-js/src/modules/networking/requests.test.js @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// pFetch is a thin wrapper over the epoxy client, so the client module is +// stubbed: the tests care about delegation, cache invalidation, and logging. +const mockGetEpoxyClient = vi.fn(); +const mockClearEpoxyClientCache = vi.fn(); +vi.mock('./index.js', () => ({ + getEpoxyClient: (...args) => mockGetEpoxyClient(...args), + clearEpoxyClientCache: (...args) => mockClearEpoxyClientCache(...args), +})); + +const { pFetch } = await import('./requests.js'); + +const origPuter = globalThis.puter; + +let mockClientFetch; +let logRequest; + +// Matches what the epoxy client resolves to: a Response-like object. +const RESPONSE = { status: 204, statusText: 'No Content' }; + +beforeEach(() => { + mockClearEpoxyClientCache.mockReset(); + mockClientFetch = vi.fn(async () => RESPONSE); + mockGetEpoxyClient.mockReset() + .mockResolvedValue({ fetch: (...args) => mockClientFetch(...args) }); + + logRequest = vi.fn(); + globalThis.puter = { + apiCallLogger: { isEnabled: () => true, logRequest }, + }; +}); + +afterEach(() => { + globalThis.puter = origPuter; +}); + +describe('pFetch delegation', () => { + it('passes its arguments straight through and returns the response', async () => { + const init = { method: 'POST', body: 'payload' }; + + const response = await pFetch('https://example.com/api', init); + + expect(response).toBe(RESPONSE); + expect(mockClientFetch).toHaveBeenCalledWith('https://example.com/api', init); + }); + + it('works with no init argument', async () => { + await pFetch('https://example.com/api'); + + expect(mockClientFetch).toHaveBeenCalledWith('https://example.com/api'); + }); + + it('rethrows a failed request', async () => { + mockClientFetch.mockRejectedValue(new Error('socket closed')); + + await expect(pFetch('https://example.com')).rejects.toThrow('socket closed'); + }); + + it('drops the cached client when the request fails', async () => { + mockClientFetch.mockRejectedValue(new Error('socket closed')); + + await expect(pFetch('https://example.com')).rejects.toThrow(); + + expect(mockClearEpoxyClientCache).toHaveBeenCalledTimes(1); + }); + + // Nothing was cached if the client itself never came up, so there is + // nothing to invalidate -- clearing here would just mask the real failure. + it('leaves the cache alone when the client could not be created', async () => { + mockGetEpoxyClient.mockRejectedValue(new Error('wasm unavailable')); + + await expect(pFetch('https://example.com')).rejects.toThrow('wasm unavailable'); + + expect(mockClearEpoxyClientCache).not.toHaveBeenCalled(); + }); +}); + +describe('pFetch api call logging', () => { + it('logs the response status on success', async () => { + await pFetch('https://example.com/api', { method: 'PUT' }); + + expect(logRequest).toHaveBeenCalledTimes(1); + expect(logRequest).toHaveBeenCalledWith(expect.objectContaining({ + service: 'network', + operation: 'pFetch', + params: { url: 'https://example.com/api', method: 'PUT' }, + result: { status: 204, statusText: 'No Content' }, + })); + }); + + it('logs the message and stack on failure', async () => { + const failure = new Error('socket closed'); + mockClientFetch.mockRejectedValue(failure); + + await expect(pFetch('https://example.com/api')).rejects.toThrow(); + + const [entry] = logRequest.mock.calls[0]; + expect(entry.error.message).toBe('socket closed'); + expect(entry.error.stack).toBe(failure.stack); + }); + + it('stringifies a non-Error rejection reason', async () => { + mockClientFetch.mockRejectedValue('just a string'); + + await expect(pFetch('https://example.com/api')).rejects.toBe('just a string'); + + const [entry] = logRequest.mock.calls[0]; + expect(entry.error.message).toBe('just a string'); + expect(entry.error.stack).toBeUndefined(); + }); + + it('stays quiet while logging is disabled', async () => { + globalThis.puter.apiCallLogger.isEnabled = () => false; + + await pFetch('https://example.com/api'); + + expect(logRequest).not.toHaveBeenCalled(); + }); + + it('does not require a puter runtime to be present', async () => { + globalThis.puter = undefined; + + await expect(pFetch('https://example.com/api')).resolves.toBe(RESPONSE); + }); + + describe('request description', () => { + it('reads a string url and defaults the method to GET', async () => { + await pFetch('https://example.com/plain'); + + const [entry] = logRequest.mock.calls[0]; + expect(entry.params).toEqual({ url: 'https://example.com/plain', method: 'GET' }); + }); + + it('serialises a URL instance', async () => { + await pFetch(new URL('https://example.com/from-url')); + + const [entry] = logRequest.mock.calls[0]; + expect(entry.params.url).toBe('https://example.com/from-url'); + }); + + it('reads url and method off a Request-like object', async () => { + await pFetch({ url: 'https://example.com/req', method: 'DELETE' }); + + const [entry] = logRequest.mock.calls[0]; + expect(entry.params).toEqual({ url: 'https://example.com/req', method: 'DELETE' }); + }); + + // An explicit init overrides the method carried by the request object. + it('prefers the init method over the request object method', async () => { + await pFetch({ url: 'https://example.com/req', method: 'DELETE' }, { method: 'PATCH' }); + + const [entry] = logRequest.mock.calls[0]; + expect(entry.params.method).toBe('PATCH'); + }); + + it('records an undefined url for an unrecognised resource', async () => { + await pFetch(42); + + const [entry] = logRequest.mock.calls[0]; + expect(entry.params).toEqual({ url: undefined, method: 'GET' }); + }); + }); +});