diff --git a/src/__tests__/integration/fixtures/bitgo/addKey.backup.json b/src/__tests__/integration/fixtures/bitgo/addKey.backup.json new file mode 100644 index 00000000..5c512b69 --- /dev/null +++ b/src/__tests__/integration/fixtures/bitgo/addKey.backup.json @@ -0,0 +1,7 @@ +{ + "id": "backup-key-id", + "pub": "xpub_backup", + "keyType": "independent", + "source": "backup", + "type": "independent" +} diff --git a/src/__tests__/integration/fixtures/bitgo/addKey.bitgo.json b/src/__tests__/integration/fixtures/bitgo/addKey.bitgo.json new file mode 100644 index 00000000..7409ec2e --- /dev/null +++ b/src/__tests__/integration/fixtures/bitgo/addKey.bitgo.json @@ -0,0 +1,10 @@ +{ + "id": "bitgo-key-id", + "pub": "xpub_bitgo", + "keyType": "independent", + "source": "bitgo", + "type": "independent", + "isBitGo": true, + "isTrust": false, + "hsmType": "institutional" +} diff --git a/src/__tests__/integration/fixtures/bitgo/addKey.user.json b/src/__tests__/integration/fixtures/bitgo/addKey.user.json new file mode 100644 index 00000000..be3f19d5 --- /dev/null +++ b/src/__tests__/integration/fixtures/bitgo/addKey.user.json @@ -0,0 +1,7 @@ +{ + "id": "user-key-id", + "pub": "xpub_user", + "keyType": "independent", + "source": "user", + "type": "independent" +} diff --git a/src/__tests__/integration/fixtures/bitgo/createWallet.json b/src/__tests__/integration/fixtures/bitgo/createWallet.json new file mode 100644 index 00000000..93ae0afe --- /dev/null +++ b/src/__tests__/integration/fixtures/bitgo/createWallet.json @@ -0,0 +1,42 @@ +{ + "id": "test-wallet-id", + "coin": "tbtc", + "label": "test-wallet", + "m": 2, + "n": 3, + "keys": ["user-key-id", "backup-key-id", "bitgo-key-id"], + "keySignatures": {}, + "type": "advanced", + "multisigType": "onchain", + "isCold": true, + "enterprise": "test-enterprise", + "organization": "test-org", + "bitgoOrg": "BitGo Inc", + "tags": ["test-wallet-id", "test-enterprise"], + "disableTransactionNotifications": false, + "freeze": {}, + "deleted": false, + "approvalsRequired": 1, + "coinSpecific": {}, + "admin": {}, + "allowBackupKeySigning": false, + "clientFlags": [], + "recoverable": false, + "startDate": "2025-01-01T00:00:00.000Z", + "hasLargeNumberOfAddresses": false, + "config": {}, + "balanceString": "0", + "confirmedBalanceString": "0", + "spendableBalanceString": "0", + "pendingApprovals": [], + "receiveAddress": { + "id": "addr-id", + "address": "0xexampleaddress", + "chain": 0, + "index": 0, + "coin": "tbtc", + "wallet": "test-wallet-id", + "coinSpecific": {} + }, + "users": [{ "user": "user-id", "permissions": ["admin", "spend", "view"] }] +} diff --git a/src/__tests__/integration/generateWallet.integ.test.ts b/src/__tests__/integration/generateWallet.integ.test.ts new file mode 100644 index 00000000..afd0ebcd --- /dev/null +++ b/src/__tests__/integration/generateWallet.integ.test.ts @@ -0,0 +1,120 @@ +import 'should'; +import { startServices, IntegServices } from './helpers/setup'; +import { LOCALHOST } from './helpers/servers'; +import { SigningMode } from '../../shared/types'; +import type { GenerateWalletResponseBody } from '../../masterBitgoExpress/routers/generateWalletRoute'; + +describe('Generate wallet: LOCAL signing', () => { + let services: IntegServices; + + before(async () => { + services = await startServices(); + }); + + after(async () => { + await services.teardown(); + }); + + beforeEach(() => { + services.keyProvider.calls.length = 0; + services.bitgo.calls.length = 0; + }); + + it('generates a tbtc onchain wallet end-to-end', async () => { + const res = await fetch( + `http://${LOCALHOST}:${services.mbePort}/api/v1/tbtc/advancedwallet/generate`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' }, + body: JSON.stringify({ + enterprise: 'test-enterprise', + label: 'test-wallet', + multisigType: 'onchain', + }), + }, + ); + + res.status.should.equal(200); + const body = (await res.json()) as GenerateWalletResponseBody; + body.should.have.property('wallet'); + body.wallet.should.have.property('id', 'test-wallet-id'); + + /** In local mode, AWM stores keys via POST /key — POST /key/generate must NOT be called */ + const keyProviderStoreCalls = services.keyProvider.calls.filter((c) => c.path === '/key'); + keyProviderStoreCalls.should.have.length(2); + + const keyProviderGenerateCalls = services.keyProvider.calls.filter( + (c) => c.path === '/key/generate', + ); + keyProviderGenerateCalls.should.have.length(0); + + /** BitGo received 3 keychain adds */ + const bitgoKeyCalls = services.bitgo.calls.filter( + (c) => c.method === 'POST' && c.path.endsWith('/key'), + ); + bitgoKeyCalls.should.have.length(3); + + /** and 1 wallet add */ + const walletAddCalls = services.bitgo.calls.filter((c) => c.path.endsWith('/wallet/add')); + walletAddCalls.should.have.length(1); + }); +}); + +describe('Generate wallet: EXTERNAL signing', () => { + let services: IntegServices; + + before(async () => { + services = await startServices({ signingMode: SigningMode.EXTERNAL }); + }); + + after(async () => { + await services.teardown(); + }); + + beforeEach(() => { + services.keyProvider.calls.length = 0; + services.bitgo.calls.length = 0; + }); + + it('generates a tbtc onchain wallet — key provider generates keys (external signing mode)', async () => { + const res = await fetch( + `http://${LOCALHOST}:${services.mbePort}/api/v1/tbtc/advancedwallet/generate`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' }, + body: JSON.stringify({ + enterprise: 'test-enterprise', + label: 'test-wallet', + multisigType: 'onchain', + }), + }, + ); + + res.status.should.equal(200); + const body = (await res.json()) as GenerateWalletResponseBody; + body.should.have.property('wallet'); + body.wallet.should.have.property('id', 'test-wallet-id'); + + /** + * In external mode, AWM delegates key generation to the key provider. + */ + const keyProviderGenerateCalls = services.keyProvider.calls.filter( + (c) => c.path === '/key/generate', + ); + keyProviderGenerateCalls.should.have.length(2); + + /** POST /key should NOT be called — AWM never generates keys locally in external mode */ + const keyProviderStoreCalls = services.keyProvider.calls.filter((c) => c.path === '/key'); + keyProviderStoreCalls.should.have.length(0); + + /** BitGo receives 3 keychain adds */ + const bitgoKeyCalls = services.bitgo.calls.filter( + (c) => c.method === 'POST' && c.path.endsWith('/key'), + ); + bitgoKeyCalls.should.have.length(3); + + /** and 1 wallet add */ + const walletAddCalls = services.bitgo.calls.filter((c) => c.path.endsWith('/wallet/add')); + walletAddCalls.should.have.length(1); + }); +}); diff --git a/src/__tests__/integration/health.integ.test.ts b/src/__tests__/integration/health.integ.test.ts index 2ec54fbc..e58357a4 100644 --- a/src/__tests__/integration/health.integ.test.ts +++ b/src/__tests__/integration/health.integ.test.ts @@ -3,9 +3,9 @@ import * as http from 'http'; import { app as awmApp } from '../../advancedWalletManagerApp'; import { app as mbeApp } from '../../masterBitGoExpressApp'; import { AppMode, TlsMode, SigningMode } from '../../shared/types'; -import { listen, close } from './helpers/servers'; +import { listen, close, LOCALHOST } from './helpers/servers'; -describe('integration — health checks', () => { +describe('Integration Test — health checks', () => { let awmServer: http.Server; let mbeServer: http.Server; let awmPort: number; @@ -18,10 +18,10 @@ describe('integration — health checks', () => { tlsMode: TlsMode.DISABLED, signingMode: SigningMode.LOCAL, port: 0, - bind: '127.0.0.1', + bind: LOCALHOST, timeout: 30000, httpLoggerFile: '', - keyProviderUrl: 'http://127.0.0.1:3082', + keyProviderUrl: `http://${LOCALHOST}:3082`, }), ); awmPort = await listen(awmServer); @@ -31,12 +31,12 @@ describe('integration — health checks', () => { appMode: AppMode.MASTER_EXPRESS, tlsMode: TlsMode.DISABLED, port: 0, - bind: '127.0.0.1', + bind: LOCALHOST, timeout: 30000, httpLoggerFile: '', env: 'test', disableEnvCheck: true, - advancedWalletManagerUrl: `http://127.0.0.1:${awmPort}`, + advancedWalletManagerUrl: `http://${LOCALHOST}:${awmPort}`, awmServerCertAllowSelfSigned: true, }), ); @@ -49,12 +49,14 @@ describe('integration — health checks', () => { }); it('AWM /ping returns 200', async () => { - const res = await fetch(`http://127.0.0.1:${awmPort}/ping`, { method: 'POST' }); + const res = await fetch(`http://${LOCALHOST}:${awmPort}/ping`, { method: 'POST' }); res.status.should.equal(200); }); it('MBE /advancedwallet/ping returns 200', async () => { - const res = await fetch(`http://127.0.0.1:${mbePort}/advancedwallet/ping`, { method: 'POST' }); + const res = await fetch(`http://${LOCALHOST}:${mbePort}/advancedwallet/ping`, { + method: 'POST', + }); res.status.should.equal(200); }); }); diff --git a/src/__tests__/integration/helpers/mockBitgoServer.ts b/src/__tests__/integration/helpers/mockBitgoServer.ts new file mode 100644 index 00000000..f2e02185 --- /dev/null +++ b/src/__tests__/integration/helpers/mockBitgoServer.ts @@ -0,0 +1,61 @@ +import * as http from 'http'; +import * as path from 'path'; +import express from 'express'; +import { listen, close } from './servers'; + +export interface MockBitgoCall { + method: string; + path: string; + body: unknown; +} + +export interface MockBitgoServer { + port: number; + calls: MockBitgoCall[]; + close(): Promise; +} + +const FIXTURES_DIR = path.resolve(__dirname, '../fixtures/bitgo'); + +function loadFixture(name: string): unknown { + return require(`${FIXTURES_DIR}/${name}.json`); +} + +export async function startMockBitgoServer(): Promise { + const calls: MockBitgoCall[] = []; + + const app = express(); + app.use(express.json()); + + app.use((req, _res, next) => { + calls.push({ method: req.method, path: req.path, body: req.body }); + next(); + }); + + /** SDK calls this on every BitGo instance initialisation */ + app.get('/api/v1/client/constants', (_req, res) => { + res.json({ ttl: 3600, constants: {} }); + }); + + /** Add keychain — source distinguishes user / backup / bitgo */ + app.post('/api/v2/:coin/key', (req, res) => { + const { coin } = req.params; + const source = req.body?.source; + const fixtureName = + source === 'user' ? 'addKey.user' : source === 'backup' ? 'addKey.backup' : 'addKey.bitgo'; + const fixture = loadFixture(fixtureName) as Record; + return res.json({ ...fixture, coin }); + }); + + /** Create wallet */ + app.post('/api/v2/:coin/wallet/add', (req, res) => { + const { coin } = req.params; + const fixture = loadFixture('createWallet') as Record; + res.json({ ...fixture, coin }); + }); + + const server = http.createServer(app); + const port = await listen(server); + + return { port, calls, close: () => close(server) }; +} diff --git a/src/__tests__/integration/helpers/mockKeyProviderServer.ts b/src/__tests__/integration/helpers/mockKeyProviderServer.ts new file mode 100644 index 00000000..69dd3d7d --- /dev/null +++ b/src/__tests__/integration/helpers/mockKeyProviderServer.ts @@ -0,0 +1,74 @@ +import * as http from 'http'; +import express from 'express'; +import { BitGoAPI } from '@bitgo-beta/sdk-api'; +import { Hteth } from '@bitgo-beta/sdk-coin-eth'; +import { Tbtc } from '@bitgo-beta/sdk-coin-btc'; +import { listen, close } from './servers'; + +export interface MockKeyProviderCall { + method: string; + path: string; + body: unknown; +} + +export interface MockKeyProviderServer { + port: number; + calls: MockKeyProviderCall[]; + close(): Promise; +} + +interface StoredKey { + prv: string; + source: string; + type: string; +} + +/** + * @returns BitGo Instance with coins registered + */ +function createBitgoInstance(): BitGoAPI { + const instance = new BitGoAPI({ env: 'test' }); + instance.register('hteth', Hteth.createInstance); + instance.register('tbtc', Tbtc.createInstance); + return instance; +} + +function generateKeypair(coin: string): { pub: string; prv: string } { + const keychain = createBitgoInstance().coin(coin).keychains().create(); + if (!keychain.pub || !keychain.prv) + throw new Error(`Failed to generate keypair for coin: ${coin}`); + return { pub: keychain.pub, prv: keychain.prv }; +} + +export async function startMockKeyProviderServer(): Promise { + const calls: MockKeyProviderCall[] = []; + const keyStore = new Map(); + + const app = express(); + app.use(express.json()); + + app.use((req, _res, next) => { + calls.push({ method: req.method, path: req.path, body: req.body }); + next(); + }); + + /** External signing mode — key provider generates the key */ + app.post('/key/generate', (req, res) => { + const { coin, source, type } = req.body; + const { pub, prv } = generateKeypair(coin); + keyStore.set(pub, { prv, source, type }); + res.json({ pub, coin, source, type }); + }); + + /** Local signing mode — AWM generates the key and sends it here for storage */ + app.post('/key', (req, res) => { + const { pub, prv, coin, source, type } = req.body; + keyStore.set(pub, { prv, source, type }); + res.json({ pub, coin, source, type }); + }); + + const server = http.createServer(app); + const port = await listen(server); + + return { port, calls, close: () => close(server) }; +} diff --git a/src/__tests__/integration/helpers/servers.ts b/src/__tests__/integration/helpers/servers.ts index b17a3f82..7b2bccca 100644 --- a/src/__tests__/integration/helpers/servers.ts +++ b/src/__tests__/integration/helpers/servers.ts @@ -1,10 +1,12 @@ import * as http from 'http'; import * as net from 'net'; +export const LOCALHOST = '127.0.0.1'; + export function listen(server: http.Server): Promise { return new Promise((resolve, reject) => { server.once('error', reject); - server.listen(0, '127.0.0.1', () => { + server.listen(0, LOCALHOST, () => { resolve((server.address() as net.AddressInfo).port); }); }); diff --git a/src/__tests__/integration/helpers/setup.ts b/src/__tests__/integration/helpers/setup.ts new file mode 100644 index 00000000..7deea057 --- /dev/null +++ b/src/__tests__/integration/helpers/setup.ts @@ -0,0 +1,68 @@ +import * as http from 'http'; +import { app as awmApp } from '../../../advancedWalletManagerApp'; +import { app as mbeApp } from '../../../masterBitGoExpressApp'; +import { AppMode, TlsMode, SigningMode } from '../../../shared/types'; +import { listen, close, LOCALHOST } from './servers'; +import { startMockKeyProviderServer, MockKeyProviderServer } from './mockKeyProviderServer'; +import { startMockBitgoServer, MockBitgoServer } from './mockBitgoServer'; + +export interface IntegServices { + mbePort: number; + keyProvider: MockKeyProviderServer; + bitgo: MockBitgoServer; + teardown(): Promise; +} + +export interface StartServicesOptions { + signingMode?: SigningMode; +} + +export async function startServices(opts: StartServicesOptions = {}): Promise { + const signingMode = opts.signingMode ?? SigningMode.LOCAL; + + const keyProvider = await startMockKeyProviderServer(); + const bitgo = await startMockBitgoServer(); + + const awmServer = http.createServer( + awmApp({ + signingMode, + appMode: AppMode.ADVANCED_WALLET_MANAGER, + tlsMode: TlsMode.DISABLED, + port: 0, + bind: LOCALHOST, + timeout: 30000, + httpLoggerFile: '', + keyProviderUrl: `http://${LOCALHOST}:${keyProvider.port}`, + }), + ); + const awmPort = await listen(awmServer); + + const mbeServer = http.createServer( + mbeApp({ + appMode: AppMode.MASTER_EXPRESS, + tlsMode: TlsMode.DISABLED, + port: 0, + bind: LOCALHOST, + timeout: 30000, + httpLoggerFile: '', + env: 'test', + disableEnvCheck: true, + advancedWalletManagerUrl: `http://${LOCALHOST}:${awmPort}`, + awmServerCertAllowSelfSigned: true, + customRootUri: `http://${LOCALHOST}:${bitgo.port}`, + }), + ); + const mbePort = await listen(mbeServer); + + return { + mbePort, + keyProvider, + bitgo, + async teardown() { + await close(mbeServer); + await close(awmServer); + await keyProvider.close(); + await bitgo.close(); + }, + }; +} diff --git a/src/masterBitgoExpress/routers/generateWalletRoute.ts b/src/masterBitgoExpress/routers/generateWalletRoute.ts index d6a9af87..a1fb2c8a 100644 --- a/src/masterBitgoExpress/routers/generateWalletRoute.ts +++ b/src/masterBitgoExpress/routers/generateWalletRoute.ts @@ -265,14 +265,18 @@ const BitgoKeychainType = t.intersection([ }), ]); +const GenerateWalletResponseCodec = t.type({ + wallet: WalletType, + userKeychain: UserKeychainType, + backupKeychain: UserKeychainType, + bitgoKeychain: BitgoKeychainType, + responseType: t.string, +}); + +export type GenerateWalletResponseBody = t.TypeOf; + const GenerateWalletResponse: HttpResponse = { - 200: t.type({ - wallet: WalletType, - userKeychain: UserKeychainType, - backupKeychain: UserKeychainType, - bitgoKeychain: BitgoKeychainType, - responseType: t.string, - }), + 200: GenerateWalletResponseCodec, ...ErrorResponses, };