Skip to content

Commit c77331f

Browse files
committed
feat: implement client to abstract bridge calls
This PR implements a client for the bridge service, which will be used by the master Bitgo Express server to forward certain requests to the bridge service. A base HTTP Client has been implemented and the the existing keyProvider has been refactored to extend it as well. Tests have been added for the new client and existing tests for the keyProvider have been updated whereby necessary(error handling changes). Ticket: WCN-743
1 parent 475942f commit c77331f

6 files changed

Lines changed: 594 additions & 105 deletions

File tree

src/__tests__/api/advancedWalletManager/keyProviderClient.test.ts

Lines changed: 14 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ describe('postMpcV2Key', () => {
9393

9494
response.status.should.equal(500);
9595
response.body.should.have.property('error', 'Internal Server Error');
96-
response.body.should.have.property('details', 'This is an error message');
96+
response.body.details.should.match(/This is an error message/);
9797
});
9898

9999
it('should handle unexpected key provider errors', async () => {
@@ -106,10 +106,7 @@ describe('postMpcV2Key', () => {
106106

107107
response.status.should.equal(500);
108108
response.body.should.have.property('error', 'Internal Server Error');
109-
response.body.should.have.property(
110-
'details',
111-
'key provider returned unexpected response. 502: Unexpected error',
112-
);
109+
response.body.details.should.match(/502.*Unexpected error/);
113110
});
114111
});
115112

@@ -150,27 +147,17 @@ describe('KeyProviderClient.generateKey', () => {
150147
});
151148

152149
[
153-
{
154-
url: endPointPath,
155-
statusCode: 400,
156-
mockedError: 'bad request',
157-
expectedError: 'bad request',
158-
},
159-
{ url: endPointPath, statusCode: 404, mockedError: 'not found', expectedError: 'not found' },
160-
{ url: endPointPath, statusCode: 409, mockedError: 'conflict', expectedError: 'conflict' },
161-
{
162-
url: endPointPath,
163-
statusCode: 500,
164-
mockedError: 'internal error',
165-
expectedError: 'internal error',
166-
},
167-
].forEach(({ url, statusCode, mockedError, expectedError }) => {
150+
{ url: endPointPath, statusCode: 400, mockedError: 'bad request' },
151+
{ url: endPointPath, statusCode: 404, mockedError: 'not found' },
152+
{ url: endPointPath, statusCode: 409, mockedError: 'conflict' },
153+
{ url: endPointPath, statusCode: 500, mockedError: 'internal error' },
154+
].forEach(({ url, statusCode, mockedError }) => {
168155
it(`should bubble up ${statusCode} errors`, async () => {
169156
const nockMocked = nock(keyProviderUrl)
170157
.post(url)
171158
.reply(statusCode, { message: mockedError })
172159
.persist();
173-
await client.generateKey(params).should.be.rejectedWith(expectedError);
160+
await client.generateKey(params).should.be.rejectedWith(new RegExp(mockedError));
174161
nockMocked.done();
175162
});
176163
});
@@ -222,17 +209,17 @@ describe('KeyProviderClient.sign', () => {
222209
});
223210

224211
[
225-
{ statusCode: 400, mockedError: 'bad request', expectedError: 'bad request' },
226-
{ statusCode: 404, mockedError: 'not found', expectedError: 'not found' },
227-
{ statusCode: 409, mockedError: 'conflict', expectedError: 'conflict' },
228-
{ statusCode: 500, mockedError: 'internal error', expectedError: 'internal error' },
229-
].forEach(({ statusCode, mockedError, expectedError }) => {
212+
{ statusCode: 400, mockedError: 'bad request' },
213+
{ statusCode: 404, mockedError: 'not found' },
214+
{ statusCode: 409, mockedError: 'conflict' },
215+
{ statusCode: 500, mockedError: 'internal error' },
216+
].forEach(({ statusCode, mockedError }) => {
230217
it(`should bubble up ${statusCode} errors`, async () => {
231218
const nockMocked = nock(keyProviderUrl)
232219
.post(endPointPath)
233220
.reply(statusCode, { message: mockedError })
234221
.persist();
235-
await client.sign(params).should.be.rejectedWith(expectedError);
222+
await client.sign(params).should.be.rejectedWith(new RegExp(mockedError));
236223
nockMocked.done();
237224
});
238225
});
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
import nock from 'nock';
2+
import 'should';
3+
import { OsoBridgeClient } from '../../../masterBitgoExpress/clients/bridgeClient';
4+
import { BridgeJobResponse } from '../../../masterBitgoExpress/clients/bridgeClient.types';
5+
import { KeySource } from '../../../shared/types';
6+
7+
const BASE_URL = 'http://bridge.invalid';
8+
const TIMEOUT = 60000;
9+
10+
const mockJob: BridgeJobResponse = {
11+
jobId: 'job-123',
12+
status: 'awaiting_bitgo',
13+
version: 1,
14+
coin: 'tbtc',
15+
operationType: 'multisig_sign',
16+
createdAt: '2026-06-09T00:00:00.000Z',
17+
updatedAt: '2026-06-09T00:00:00.000Z',
18+
};
19+
20+
describe('OsoBridgeClient', () => {
21+
let client: OsoBridgeClient;
22+
23+
before(() => {
24+
nock.disableNetConnect();
25+
client = new OsoBridgeClient(BASE_URL, TIMEOUT);
26+
});
27+
28+
afterEach(() => nock.cleanAll());
29+
30+
describe('constructor', () => {
31+
it('throws when url is empty', () => {
32+
(() => new OsoBridgeClient('', TIMEOUT)).should.throw('OsoBridgeClient: awmAsyncUrl is required');
33+
});
34+
35+
it('strips trailing slash from url', () => {
36+
const c = new OsoBridgeClient(`${BASE_URL}/`, TIMEOUT);
37+
nock(BASE_URL).get('/health').reply(200, { status: 'ok' });
38+
return c.health().should.be.fulfilled();
39+
});
40+
});
41+
42+
describe('submit()', () => {
43+
it('sets X-OSO-Source for a single source', async () => {
44+
const n = nock(BASE_URL)
45+
.post('/api/tbtc/multisig/sign')
46+
.matchHeader('x-oso-source', 'user')
47+
.reply(202, { jobId: 'job-123' });
48+
49+
await client.submit({
50+
path: '/api/tbtc/multisig/sign',
51+
body: { foo: 'bar' },
52+
sources: [KeySource.USER],
53+
operationType: 'multisig_sign',
54+
});
55+
56+
n.done();
57+
});
58+
59+
it('sets X-OSO-Source as comma-joined for multiple sources', async () => {
60+
const n = nock(BASE_URL)
61+
.post('/api/tbtc/key/independent')
62+
.matchHeader('x-oso-source', 'user,backup')
63+
.reply(202, { jobId: 'job-456' });
64+
65+
await client.submit({
66+
path: '/api/tbtc/key/independent',
67+
body: { foo: 'bar' },
68+
sources: [KeySource.USER, KeySource.BACKUP],
69+
operationType: 'multisig_keygen',
70+
});
71+
72+
n.done();
73+
});
74+
75+
it('sets X-OSO-Operation header', async () => {
76+
const n = nock(BASE_URL)
77+
.post('/api/tbtc/multisig/sign')
78+
.matchHeader('x-oso-operation', 'multisig_sign')
79+
.reply(202, { jobId: 'job-123' });
80+
81+
await client.submit({
82+
path: '/api/tbtc/multisig/sign',
83+
body: { foo: 'bar' },
84+
sources: [KeySource.USER],
85+
operationType: 'multisig_sign',
86+
});
87+
88+
n.done();
89+
});
90+
91+
it('sets X-Idempotency-Key when provided', async () => {
92+
const n = nock(BASE_URL)
93+
.post('/api/tbtc/multisig/sign')
94+
.matchHeader('x-idempotency-key', 'idem-key-abc')
95+
.reply(202, { jobId: 'job-123' });
96+
97+
await client.submit({
98+
path: '/api/tbtc/multisig/sign',
99+
body: { foo: 'bar' },
100+
sources: [KeySource.USER],
101+
operationType: 'multisig_sign',
102+
idempotencyKey: 'idem-key-abc',
103+
});
104+
105+
n.done();
106+
});
107+
108+
it('omits X-Idempotency-Key when not provided', async () => {
109+
const n = nock(BASE_URL)
110+
.post('/api/tbtc/multisig/sign')
111+
.matchHeader('x-idempotency-key', (val) => val === undefined)
112+
.reply(202, { jobId: 'job-123' });
113+
114+
await client.submit({
115+
path: '/api/tbtc/multisig/sign',
116+
body: { foo: 'bar' },
117+
sources: [KeySource.USER],
118+
operationType: 'multisig_sign',
119+
});
120+
121+
n.done();
122+
});
123+
124+
it('forwards body as-is', async () => {
125+
const body = { signablePayload: 'deadbeef', walletId: 'w-123' };
126+
const n = nock(BASE_URL)
127+
.post('/api/tbtc/multisig/sign', body)
128+
.reply(202, { jobId: 'job-123' });
129+
130+
await client.submit({
131+
path: '/api/tbtc/multisig/sign',
132+
body,
133+
sources: [KeySource.USER],
134+
operationType: 'multisig_sign',
135+
});
136+
137+
n.done();
138+
});
139+
140+
it('returns jobId from response', async () => {
141+
nock(BASE_URL).post('/api/tbtc/multisig/sign').reply(202, { jobId: 'job-789' });
142+
143+
const result = await client.submit({
144+
path: '/api/tbtc/multisig/sign',
145+
body: {},
146+
sources: [KeySource.USER],
147+
operationType: 'multisig_sign',
148+
});
149+
150+
result.should.have.property('jobId', 'job-789');
151+
});
152+
153+
it('normalizes path without leading slash', async () => {
154+
const n = nock(BASE_URL).post('/api/tbtc/multisig/sign').reply(202, { jobId: 'job-123' });
155+
156+
await client.submit({
157+
path: 'api/tbtc/multisig/sign',
158+
body: {},
159+
sources: [KeySource.USER],
160+
operationType: 'multisig_sign',
161+
});
162+
163+
n.done();
164+
});
165+
166+
it('throws on invalid response shape', async () => {
167+
nock(BASE_URL).post('/api/tbtc/multisig/sign').reply(202, { notAJobId: true });
168+
169+
await client
170+
.submit({
171+
path: '/api/tbtc/multisig/sign',
172+
body: {},
173+
sources: [KeySource.USER],
174+
operationType: 'multisig_sign',
175+
})
176+
.should.be.rejectedWith(/bridge returned unexpected response for submit/);
177+
});
178+
179+
it('throws on 400', async () => {
180+
nock(BASE_URL).post('/api/tbtc/multisig/sign').reply(400, { message: 'bad input' });
181+
182+
await client
183+
.submit({
184+
path: '/api/tbtc/multisig/sign',
185+
body: {},
186+
sources: [KeySource.USER],
187+
operationType: 'multisig_sign',
188+
})
189+
.should.be.rejectedWith('bad input');
190+
});
191+
});
192+
193+
describe('getJob()', () => {
194+
it('GETs /job/:jobId and returns BridgeJobResponse', async () => {
195+
const n = nock(BASE_URL).get('/job/job-123').reply(200, mockJob);
196+
197+
const result = await client.getJob('job-123');
198+
199+
result.should.have.property('jobId', 'job-123');
200+
result.should.have.property('status', 'awaiting_bitgo');
201+
n.done();
202+
});
203+
204+
it('throws on 404', async () => {
205+
nock(BASE_URL).get('/job/missing').reply(404, { message: 'job not found' });
206+
207+
await client.getJob('missing').should.be.rejectedWith('job not found');
208+
});
209+
210+
it('throws on invalid response shape', async () => {
211+
nock(BASE_URL).get('/job/job-123').reply(200, { bad: 'shape' });
212+
213+
await client
214+
.getJob('job-123')
215+
.should.be.rejectedWith(/bridge returned unexpected response for getJob/);
216+
});
217+
});
218+
219+
describe('updateJob()', () => {
220+
it('PATCHes /job/:jobId with correct body', async () => {
221+
const n = nock(BASE_URL)
222+
.patch('/job/job-123', { version: 1, status: 'complete', result: { txid: 'abc' } })
223+
.reply(204);
224+
225+
await client.updateJob({
226+
jobId: 'job-123',
227+
version: 1,
228+
status: 'complete',
229+
result: { txid: 'abc' },
230+
});
231+
232+
n.done();
233+
});
234+
235+
it('returns void on 204', async () => {
236+
nock(BASE_URL).patch('/job/job-123').reply(204);
237+
238+
const result = await client.updateJob({
239+
jobId: 'job-123',
240+
version: 1,
241+
status: 'failed',
242+
error: 'timeout',
243+
});
244+
245+
(result === undefined).should.be.true();
246+
});
247+
248+
it('throws on 409', async () => {
249+
nock(BASE_URL).patch('/job/job-123').reply(409, { message: 'version conflict' });
250+
251+
await client
252+
.updateJob({ jobId: 'job-123', version: 1, status: 'complete' })
253+
.should.be.rejectedWith('version conflict');
254+
});
255+
});
256+
257+
describe('listJobs()', () => {
258+
it('GETs /jobs without query when status omitted', async () => {
259+
const n = nock(BASE_URL)
260+
.get('/jobs')
261+
.reply(200, { jobs: [mockJob] });
262+
263+
const result = await client.listJobs({});
264+
265+
result.jobs.should.have.length(1);
266+
result.jobs[0].should.have.property('jobId', 'job-123');
267+
n.done();
268+
});
269+
270+
it('GETs /jobs?status=awaiting_bitgo when status provided', async () => {
271+
const n = nock(BASE_URL)
272+
.get('/jobs')
273+
.query({ status: 'awaiting_bitgo' })
274+
.reply(200, { jobs: [mockJob] });
275+
276+
const result = await client.listJobs({ status: 'awaiting_bitgo' });
277+
278+
result.jobs.should.have.length(1);
279+
n.done();
280+
});
281+
282+
it('returns empty array when jobs list is empty', async () => {
283+
nock(BASE_URL).get('/jobs').reply(200, { jobs: [] });
284+
285+
const result = await client.listJobs({});
286+
287+
result.jobs.should.have.length(0);
288+
});
289+
});
290+
291+
describe('health()', () => {
292+
it('GETs /health and returns body', async () => {
293+
const n = nock(BASE_URL).get('/health').reply(200, { status: 'ok' });
294+
295+
const result = await client.health();
296+
297+
result.should.have.property('status', 'ok');
298+
n.done();
299+
});
300+
});
301+
302+
describe('error handling', () => {
303+
it('throws on 401', async () => {
304+
nock(BASE_URL).get('/job/job-123').reply(401, { message: 'unauthorized' });
305+
306+
await client.getJob('job-123').should.be.rejectedWith('unauthorized');
307+
});
308+
309+
it('throws with status code on unexpected status', async () => {
310+
nock(BASE_URL).get('/job/job-123').reply(503, { message: 'service unavailable' });
311+
312+
await client.getJob('job-123').should.be.rejectedWith(/503/);
313+
});
314+
});
315+
});

0 commit comments

Comments
 (0)