Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,30 @@ Requires Node.js 20+.

After merge to `main`, the generated reference can be published to GitHub Pages once Pages is set to deploy from GitHub Actions.

## Testing with the mock server

Tests that touch Horizon or Soroban RPC can use the mock server instead of
hand-rolling `fetch` mocks. It serves realistic Horizon account records and
Soroban JSON-RPC responses, and supports scripted failures (timeouts, 5xx,
malformed bodies):

```ts
import { createMockServer } from '@vero-protocol/sdk/testing';

const server = createMockServer();
server.failNext('https://primary.example', { type: 'http', status: 503 });

const rpc = new RpcClient({
endpoints: ['https://primary.example', 'https://backup.example'],
fetchImpl: server.fetch,
});

const account = await rpc.request('/accounts/GABC...'); // served from backup
```

The mock lives behind the `@vero-protocol/sdk/testing` subpath and is never
part of the main bundle.

## Bundle-size budget

This SDK is intended for browser applications, so consumer bundle size is a
Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@
"require": "./dist/cjs/index.js",
"default": "./dist/cjs/index.js"
},
"./testing": {
"types": "./dist/cjs/testing/index.d.ts",
"import": "./dist/esm/testing/index.js",
"require": "./dist/cjs/testing/index.js",
"default": "./dist/cjs/testing/index.js"
},
"./package.json": "./package.json"
},
"sideEffects": false,
Expand Down
16 changes: 15 additions & 1 deletion scripts/smoke-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,18 @@ const viaMapImport = await import('@vero-protocol/sdk');
assert(viaMapImport.RpcClient !== undefined,
'exports map: import("@vero-protocol/sdk") did not resolve to the ESM build');

console.log(`smoke-test: OK — ${REQUIRED_EXPORTS.length} exports verified in both CJS and ESM (direct + exports map), declarations present`);
// The testing subpath must resolve independently of the main entry.
const testingCjs = require('@vero-protocol/sdk/testing');
assert(typeof testingCjs.createMockServer === 'function',
'exports map: require("@vero-protocol/sdk/testing") did not resolve to the CJS build');

const testingEsm = await import('@vero-protocol/sdk/testing');
assert(typeof testingEsm.createMockServer === 'function',
'exports map: import("@vero-protocol/sdk/testing") did not resolve to the ESM build');

for (const format of ['cjs', 'esm']) {
const dts = join(root, 'dist', format, 'testing', 'index.d.ts');
accessSync(dts, constants.R_OK);
}

console.log(`smoke-test: OK — ${REQUIRED_EXPORTS.length} exports verified in both CJS and ESM (direct + exports map), declarations present; testing subpath resolves in both formats`);
109 changes: 57 additions & 52 deletions src/__tests__/rpc.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { RpcClient } from '../rpc';
import { VeroError, VeroErrorCode } from '../errors';

/** Minimal Response stand-in — avoids depending on a DOM/undici Response. */
const res = (status: number, body: unknown = {}): Response =>
({
ok: status >= 200 && status < 300,
status,
json: async () => body,
}) as Response;
import { createMockServer, type MockServer } from '../testing';

describe('RpcClient', () => {
let server: MockServer;

beforeEach(() => {
server = createMockServer();
});

it('requires at least one endpoint', () => {
expect(() => new RpcClient({ endpoints: [], fetchImpl: jest.fn() })).toThrow(VeroError);
});
Expand All @@ -21,46 +20,46 @@ describe('RpcClient', () => {
});

it('returns the parsed body on success', async () => {
const fetchImpl = jest.fn().mockResolvedValue(res(200, { ok: true }));
const client = new RpcClient({ endpoints: ['https://a.example'], fetchImpl });
await expect(client.request('/accounts/GABC')).resolves.toEqual({ ok: true });
const client = new RpcClient({ endpoints: ['https://a.example'], fetchImpl: server.fetch });
await expect(client.request('/accounts/GABC')).resolves.toMatchObject({
account_id: 'GABC',
});
});

it('falls over to the next endpoint on transport failure', async () => {
const fetchImpl = jest
.fn()
.mockRejectedValueOnce(new Error('ECONNREFUSED'))
.mockResolvedValueOnce(res(200, { via: 'second' }));
server.failNext('a.example', { type: 'network', error: new Error('ECONNREFUSED') });

const client = new RpcClient({
endpoints: ['https://a.example', 'https://b.example'],
fetchImpl,
fetchImpl: server.fetch,
});

await expect(client.request('/x')).resolves.toEqual({ via: 'second' });
expect(fetchImpl).toHaveBeenCalledTimes(2);
await expect(client.request('/accounts/GABC')).resolves.toMatchObject({
account_id: 'GABC',
});
expect(server.requests).toHaveLength(2);
});

it('treats 5xx as a transport failure and fails over', async () => {
const fetchImpl = jest
.fn()
.mockResolvedValueOnce(res(503))
.mockResolvedValueOnce(res(200, { via: 'second' }));
server.failNext('a.example', { type: 'http', status: 503 });

const client = new RpcClient({
endpoints: ['https://a.example', 'https://b.example'],
fetchImpl,
fetchImpl: server.fetch,
});

await expect(client.request('/x')).resolves.toEqual({ via: 'second' });
await expect(client.request('/accounts/GABC')).resolves.toMatchObject({
account_id: 'GABC',
});
});

// Regression guard for vero-core-engine#182.
it('does NOT penalise an endpoint for an application-level 4xx', async () => {
const fetchImpl = jest.fn().mockResolvedValue(res(404));
server.failNext('a.example', { type: 'http', status: 404 });

const client = new RpcClient({
endpoints: ['https://a.example', 'https://b.example'],
fetchImpl,
fetchImpl: server.fetch,
failureThreshold: 1,
});

Expand All @@ -69,83 +68,89 @@ describe('RpcClient', () => {
});

// Only the first endpoint was tried, and it stays healthy.
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(server.requests).toHaveLength(1);
expect(client.health().every((h) => h.healthy)).toBe(true);
});

it('quarantines an endpoint after the failure threshold', async () => {
const fetchImpl = jest.fn().mockRejectedValue(new Error('ECONNREFUSED'));
server.handle('a.example', () => {
throw new Error('ECONNREFUSED');
});

const client = new RpcClient({
endpoints: ['https://a.example'],
fetchImpl,
fetchImpl: server.fetch,
failureThreshold: 2,
});

await expect(client.request('/x')).rejects.toThrow(VeroError);
await expect(client.request('/accounts/GABC')).rejects.toThrow(VeroError);
expect(client.health()[0]?.healthy).toBe(true); // 1 failure, below threshold

await expect(client.request('/x')).rejects.toThrow(VeroError);
await expect(client.request('/accounts/GABC')).rejects.toThrow(VeroError);
expect(client.health()[0]?.healthy).toBe(false);
});

it('throws ALL_ENDPOINTS_FAILED when nothing succeeds', async () => {
const fetchImpl = jest.fn().mockRejectedValue(new Error('ECONNREFUSED'));
server.handle(() => true, () => {
throw new Error('ECONNREFUSED');
});

const client = new RpcClient({
endpoints: ['https://a.example', 'https://b.example'],
fetchImpl,
fetchImpl: server.fetch,
});

await expect(client.request('/x')).rejects.toMatchObject({
await expect(client.request('/accounts/GABC')).rejects.toMatchObject({
code: VeroErrorCode.AllEndpointsFailed,
});
});

it('resets a consecutive-failure count after a success', async () => {
const fetchImpl = jest
.fn()
.mockRejectedValueOnce(new Error('ECONNREFUSED'))
.mockResolvedValueOnce(res(200, {}));

const client = new RpcClient({ endpoints: ['https://a.example'], fetchImpl });
await expect(client.request('/x')).rejects.toThrow();
await expect(client.request('/x')).resolves.toEqual({});
server.failNext('a.example', { type: 'network', error: new Error('ECONNREFUSED') });

const client = new RpcClient({ endpoints: ['https://a.example'], fetchImpl: server.fetch });
await expect(client.request('/accounts/GABC')).rejects.toThrow();
await expect(client.request('/accounts/GABC')).resolves.toMatchObject({
account_id: 'GABC',
});
expect(client.health()[0]?.consecutiveFailures).toBe(0);
});

// Regression guard for the SSRF pattern in vero-audit-guard#302.
it('refuses a path that would escape the endpoint origin', async () => {
const fetchImpl = jest.fn().mockResolvedValue(res(200, {}));
const client = new RpcClient({ endpoints: ['https://a.example'], fetchImpl });
const client = new RpcClient({ endpoints: ['https://a.example'], fetchImpl: server.fetch });

await expect(client.request('https://evil.example/steal')).rejects.toMatchObject({
code: VeroErrorCode.InvalidUrl,
});
expect(fetchImpl).not.toHaveBeenCalled();
expect(server.requests).toHaveLength(0);
});

it('honours endpoint priority', async () => {
const fetchImpl = jest.fn().mockResolvedValue(res(200, {}));
const client = new RpcClient({
endpoints: [
{ url: 'https://low.example', priority: 10 },
{ url: 'https://high.example', priority: 1 },
],
fetchImpl,
fetchImpl: server.fetch,
});

await client.request('/x');
expect(fetchImpl.mock.calls[0][0]).toContain('high.example');
await client.request('/accounts/GABC');
expect(server.requests[0]?.url).toContain('high.example');
});

it('resetHealth clears quarantines', async () => {
const fetchImpl = jest.fn().mockRejectedValue(new Error('ECONNREFUSED'));
server.handle('a.example', () => {
throw new Error('ECONNREFUSED');
});

const client = new RpcClient({
endpoints: ['https://a.example'],
fetchImpl,
fetchImpl: server.fetch,
failureThreshold: 1,
});

await expect(client.request('/x')).rejects.toThrow();
await expect(client.request('/accounts/GABC')).rejects.toThrow();
expect(client.health()[0]?.healthy).toBe(false);

client.resetHealth();
Expand Down
Loading
Loading