diff --git a/examples/ts/share-wallet.ts b/examples/ts/share-wallet.ts index 893931fd07..56a0cc743e 100644 --- a/examples/ts/share-wallet.ts +++ b/examples/ts/share-wallet.ts @@ -22,14 +22,14 @@ bitgo.register(coin, Tbtc.createInstance); const walletId = ''; // TODO: set BitGo account email of wallet share recipient -const recipient = null; +const recipient = "recipient_email"; // TODO: set share permissions as a comma-separated list // Valid permissions to choose from are: view, spend, manage, admin const perms = 'view'; // TODO: provide the passphrase for the wallet being shared -const passphrase = null; +const passphrase = "passhrase"; async function main() { const wallet = await bitgo.coin(coin).wallets().get({ id: walletId }); diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index b9572b7d0c..2a9364c4fe 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -687,11 +687,11 @@ export function handleV2CreateLocalKeyChain(req: ExpressApiRouteRequest<'express * handle wallet share * @param req */ -async function handleV2ShareWallet(req: express.Request) { +export async function handleV2ShareWallet(req: ExpressApiRouteRequest<'express.v2.wallet.share', 'post'>) { const bitgo = req.bitgo; - const coin = bitgo.coin(req.params.coin); - const wallet = await coin.wallets().get({ id: req.params.id }); - return wallet.shareWallet(req.body); + const coin = bitgo.coin(req.decoded.coin); + const wallet = await coin.wallets().get({ id: req.decoded.id }); + return wallet.shareWallet(req.decoded); } /** @@ -1619,8 +1619,7 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { router.post('express.v2.wallet.createAddress', [prepareBitGo(config), typedPromiseWrapper(handleV2CreateAddress)]); - // share wallet - app.post('/api/v2/:coin/wallet/:id/share', parseBody, prepareBitGo(config), promiseWrapper(handleV2ShareWallet)); + router.post('express.v2.wallet.share', [prepareBitGo(config), typedPromiseWrapper(handleV2ShareWallet)]); app.post( '/api/v2/:coin/walletshare/:id/acceptshare', parseBody, diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index ee377b2ac8..872d19e8d5 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -29,6 +29,7 @@ import { PostWalletRecoverToken } from './v2/walletRecoverToken'; import { PostCoinSignTx } from './v2/coinSignTx'; import { PostWalletSignTx } from './v2/walletSignTx'; import { PostWalletTxSignTSS } from './v2/walletTxSignTSS'; +import { PostShareWallet } from './v2/shareWallet'; export const ExpressApi = apiSpec({ 'express.ping': { @@ -112,6 +113,9 @@ export const ExpressApi = apiSpec({ 'express.v2.wallet.signtxtss': { post: PostWalletTxSignTSS, }, + 'express.v2.wallet.share': { + post: PostShareWallet, + }, }); export type ExpressApi = typeof ExpressApi; diff --git a/modules/express/src/typedRoutes/api/v2/shareWallet.ts b/modules/express/src/typedRoutes/api/v2/shareWallet.ts new file mode 100644 index 0000000000..e4bd1b2bba --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/shareWallet.ts @@ -0,0 +1,88 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; +import { ShareState, ShareWalletKeychain } from '../../schemas/wallet'; + +/** + * Path parameters for sharing a wallet + */ +export const ShareWalletParams = { + /** Coin ticker / chain identifier */ + coin: t.string, + /** Wallet ID */ + id: t.string, +} as const; + +/** + * Request body for sharing a wallet + */ +export const ShareWalletBody = { + /** Recipient email address */ + email: t.string, + /** Permissions string, e.g., "view,spend" */ + permissions: t.string, + /** Wallet passphrase used to derive shared key when needed */ + walletPassphrase: optional(t.string), + /** Optional message to include with the share */ + message: optional(t.string), + /** If true, allows sharing without a keychain */ + reshare: optional(t.boolean), + /** If true, skips sharing the wallet keychain with the recipient */ + skipKeychain: optional(t.boolean), + /** If true, suppresses email notification to the recipient */ + disableEmail: optional(t.boolean), +} as const; + +/** + * Response for sharing a wallet + */ +export const ShareWalletResponse200 = t.intersection([ + t.type({ + /** Wallet share id */ + id: t.string, + /** Coin of the wallet */ + coin: t.string, + /** Wallet id */ + wallet: t.string, + /** Id of the sharer */ + fromUser: t.string, + /** Id of the recipient */ + toUser: t.string, + /** Comma-separated list of privileges for wallet */ + permissions: t.string, + }), + t.partial({ + /** Wallet label */ + walletLabel: t.string, + /** User-readable message */ + message: t.string, + /** Share state */ + state: ShareState, + /** Enterprise id, if applicable */ + enterprise: t.string, + /** Pending approval id, if one was generated */ + pendingApprovalId: t.string, + /** Included if shared with spend permission */ + keychain: ShareWalletKeychain, + }), +]); + +export const ShareWalletResponse = { + 200: ShareWalletResponse200, + 400: BitgoExpressError, +} as const; + +/** + * Share this wallet with another BitGo user. + * + * @operationId express.v2.wallet.share + */ +export const PostShareWallet = httpRoute({ + path: '/api/v2/{coin}/wallet/{id}/share', + method: 'POST', + request: httpRequest({ + params: ShareWalletParams, + body: ShareWalletBody, + }), + response: ShareWalletResponse, +}); diff --git a/modules/express/src/typedRoutes/schemas/wallet.ts b/modules/express/src/typedRoutes/schemas/wallet.ts new file mode 100644 index 0000000000..cb0311c85e --- /dev/null +++ b/modules/express/src/typedRoutes/schemas/wallet.ts @@ -0,0 +1,17 @@ +import * as t from 'io-ts'; + +export const ShareState = t.union([ + t.literal('pendingapproval'), + t.literal('active'), + t.literal('accepted'), + t.literal('canceled'), + t.literal('rejected'), +]); + +export const ShareWalletKeychain = t.partial({ + pub: t.string, + encryptedPrv: t.string, + fromPubKey: t.string, + toPubKey: t.string, + path: t.string, +}); diff --git a/modules/express/test/unit/clientRoutes/shareWallet.ts b/modules/express/test/unit/clientRoutes/shareWallet.ts new file mode 100644 index 0000000000..4b3a92dbbf --- /dev/null +++ b/modules/express/test/unit/clientRoutes/shareWallet.ts @@ -0,0 +1,98 @@ +import * as sinon from 'sinon'; +import 'should-http'; +import 'should-sinon'; +import '../../lib/asserts'; +import nock from 'nock'; +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { BitGo } from 'bitgo'; +import { BaseCoin, Wallets, Wallet, decodeOrElse, common } from '@bitgo/sdk-core'; +import { ExpressApiRouteRequest } from '../../../src/typedRoutes/api'; +import { handleV2ShareWallet } from '../../../src/clientRoutes'; +import { ShareWalletResponse } from '../../../src/typedRoutes/api/v2/shareWallet'; + +describe('Share Wallet (typed handler)', () => { + let bitgo: TestBitGoAPI; + + before(async function () { + if (!nock.isActive()) { + nock.activate(); + } + bitgo = TestBitGo.decorate(BitGo, { env: 'test' }); + bitgo.initializeTestVars(); + nock.disableNetConnect(); + nock.enableNetConnect('127.0.0.1'); + }); + + after(() => { + if (nock.isActive()) { + nock.restore(); + } + }); + + it('should call shareWallet (no stub), mock BitGo HTTP, and return typed response', async () => { + const coin = 'tbtc'; + const walletId = '59cd72485007a239fb00282ed480da1f'; + const email = 'user@example.com'; + const permissions = 'view'; + const message = 'hello'; + + const baseCoin = bitgo.coin(coin); + const walletData = { + id: walletId, + coin: coin, + keys: ['k1', 'k2', 'k3'], + coinSpecific: {}, + multisigType: 'onchain', + type: 'hot', + }; + const realWallet = new Wallet(bitgo, baseCoin, walletData); + const coinStub = sinon.createStubInstance(BaseCoin, { + wallets: sinon.stub<[], Wallets>().returns({ + get: sinon.stub<[any], Promise>().resolves(realWallet), + } as any), + }); + + const stubBitgo = sinon.createStubInstance(BitGo, { coin: sinon.stub<[string]>().returns(coinStub) }); + + const bgUrl = common.Environments[bitgo.getEnv()].uri; + const getSharingKeyNock = nock(bgUrl).post('/api/v1/user/sharingkey', { email }).reply(200, { userId: 'u1' }); + const shareResponse = { + id: walletId, + coin, + wallet: walletId, + fromUser: 'u0', + toUser: 'u1', + permissions, + message, + state: 'active', + }; + const createShareNock = nock(bgUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/share`, (body) => { + body.user.should.equal('u1'); + body.permissions.should.equal(permissions); + body.skipKeychain.should.equal(true); + if (message) body.message.should.equal(message); + return true; + }) + .reply(200, shareResponse); + + const req = { + bitgo: stubBitgo, + decoded: { + coin, + id: walletId, + email, + permissions, + message, + }, + } as unknown as ExpressApiRouteRequest<'express.v2.wallet.share', 'post'>; + + const res = await handleV2ShareWallet(req); + decodeOrElse('ShareWalletResponse200', ShareWalletResponse[200], res, (errors) => { + throw new Error(`Response did not match expected codec: ${errors}`); + }); + + getSharingKeyNock.isDone().should.be.true(); + createShareNock.isDone().should.be.true(); + }); +}); diff --git a/modules/express/test/unit/typedRoutes/shareWallet.ts b/modules/express/test/unit/typedRoutes/shareWallet.ts new file mode 100644 index 0000000000..4ca5288ea1 --- /dev/null +++ b/modules/express/test/unit/typedRoutes/shareWallet.ts @@ -0,0 +1,330 @@ +import * as assert from 'assert'; +import * as t from 'io-ts'; +import * as sinon from 'sinon'; +import { agent as supertest } from 'supertest'; +import 'should'; +import 'should-http'; +import 'should-sinon'; +import '../../lib/asserts'; +import { BitGo } from 'bitgo'; +import { + ShareWalletParams, + ShareWalletBody, + ShareWalletResponse, + PostShareWallet, +} from '../../../src/typedRoutes/api/v2/shareWallet'; +import { assertDecode } from './common'; +import { app } from '../../../src/expressApp'; +import { DefaultConfig } from '../../../src/config'; + +describe('ShareWallet API Tests', function () { + let agent: ReturnType; + + before(function () { + const testApp = app(DefaultConfig); + agent = supertest(testApp); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('Success Cases', function () { + it('should successfully share wallet with view permissions (no keychain)', async function () { + const coin = 'tbtc'; + const walletId = '59cd72485007a239fb00282ed480da1f'; + const email = 'viewer@example.com'; + const permissions = 'view'; + const message = 'Sharing for review'; + + const shareResponse = { + id: 'share123', + coin, + wallet: walletId, + fromUser: 'fromUser456', + toUser: 'toUserId123', + permissions, + message, + state: 'active', + }; + + // Stub wallet.shareWallet to return the expected response + const shareWalletStub = sinon.stub().resolves(shareResponse); + const walletStub = { + shareWallet: shareWalletStub, + } as any; + + // Stub the wallet retrieval chain + const getWalletStub = sinon.stub().resolves(walletStub); + const walletsStub = { get: getWalletStub } as any; + const coinStub = { wallets: sinon.stub().returns(walletsStub) } as any; + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/share`).send({ + email, + permissions, + message, + }); + + res.status.should.equal(200); + res.body.should.have.property('id', 'share123'); + res.body.should.have.property('wallet', walletId); + res.body.should.have.property('permissions', permissions); + res.body.should.have.property('message', message); + }); + + it('should successfully share wallet with optional skipKeychain flag', async function () { + const coin = 'tbtc'; + const walletId = '59cd72485007a239fb00282ed480da1f'; + const email = 'spender@example.com'; + const permissions = 'view,spend'; + + const shareResponse = { + id: 'share456', + coin, + wallet: walletId, + fromUser: 'fromUser456', + toUser: 'toUserId789', + permissions, + state: 'active', + }; + + const shareWalletStub = sinon.stub().resolves(shareResponse); + const walletStub = { shareWallet: shareWalletStub } as any; + const getWalletStub = sinon.stub().resolves(walletStub); + const walletsStub = { get: getWalletStub } as any; + const coinStub = { wallets: sinon.stub().returns(walletsStub) } as any; + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/share`).send({ + email, + permissions, + skipKeychain: true, + }); + + res.status.should.equal(200); + res.body.should.have.property('id', 'share456'); + }); + + it('should successfully share wallet with optional disableEmail flag', async function () { + const coin = 'tbtc'; + const walletId = '59cd72485007a239fb00282ed480da1f'; + const email = 'disableemail@example.com'; + const permissions = 'view'; + + const shareResponse = { + id: 'share555', + coin, + wallet: walletId, + fromUser: 'fromUser456', + toUser: 'toUserId555', + permissions, + state: 'active', + }; + + const shareWalletStub = sinon.stub().resolves(shareResponse); + const walletStub = { shareWallet: shareWalletStub } as any; + const getWalletStub = sinon.stub().resolves(walletStub); + const walletsStub = { get: getWalletStub } as any; + const coinStub = { wallets: sinon.stub().returns(walletsStub) } as any; + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/share`).send({ + email, + permissions, + disableEmail: true, + }); + + res.status.should.equal(200); + res.body.should.have.property('id', 'share555'); + }); + }); + + describe('Error Cases', function () { + it('should return 500 when wallet not found', async function () { + const coin = 'tbtc'; + const walletId = 'nonexistent_wallet_id'; + const email = 'user@example.com'; + const permissions = 'view'; + + // Stub coin.wallets().get() to reject with error + const getWalletStub = sinon.stub().rejects(new Error('wallet not found')); + const walletsStub = { get: getWalletStub } as any; + const coinStub = { wallets: sinon.stub().returns(walletsStub) } as any; + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/share`).send({ + email, + permissions, + }); + + res.status.should.equal(500); + res.body.should.have.property('error'); + }); + + it('should return 500 when shareWallet fails', async function () { + const coin = 'tbtc'; + const walletId = '59cd72485007a239fb00282ed480da1f'; + const email = 'invalid@example.com'; + const permissions = 'view'; + + // Stub wallet.shareWallet to reject with error + const shareWalletStub = sinon.stub().rejects(new Error('invalid email address')); + const walletStub = { shareWallet: shareWalletStub } as any; + const getWalletStub = sinon.stub().resolves(walletStub); + const walletsStub = { get: getWalletStub } as any; + const coinStub = { wallets: sinon.stub().returns(walletsStub) } as any; + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/share`).send({ + email, + permissions, + }); + + res.status.should.equal(500); + res.body.should.have.property('error'); + }); + + it('should return 400 when email is missing', async function () { + const coin = 'tbtc'; + const walletId = '59cd72485007a239fb00282ed480da1f'; + const permissions = 'view'; + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/share`).send({ + permissions, + // email is missing + }); + + res.status.should.equal(400); + res.body.should.be.an.Array(); + res.body[0].should.match(/email/); + }); + + it('should return 400 when permissions is missing', async function () { + const coin = 'tbtc'; + const walletId = '59cd72485007a239fb00282ed480da1f'; + const email = 'test@example.com'; + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/share`).send({ + email, + // permissions is missing + }); + + res.status.should.equal(400); + res.body.should.be.an.Array(); + res.body[0].should.match(/permissions/); + }); + + it('should return 400 when request body has invalid types', async function () { + const coin = 'tbtc'; + const walletId = '59cd72485007a239fb00282ed480da1f'; + + const res = await agent.post(`/api/v2/${coin}/wallet/${walletId}/share`).send({ + email: 123, // should be string + permissions: 'view', + }); + + res.status.should.equal(400); + res.body.should.be.an.Array(); + res.body[0].should.match(/email.*string/); + }); + }); + + describe('Codec Validation', function () { + it('should validate ShareWalletParams with required coin and id', function () { + const validParams = { + coin: 'tbtc', + id: '59cd72485007a239fb00282ed480da1f', + }; + + const decodedParams = assertDecode(t.exact(t.type(ShareWalletParams)), validParams); + assert.ok(decodedParams); + assert.strictEqual(decodedParams.coin, 'tbtc'); + assert.strictEqual(decodedParams.id, '59cd72485007a239fb00282ed480da1f'); + }); + + it('should validate ShareWalletBody with required fields', function () { + const validBody = { + email: 'test@example.com', + permissions: 'view,spend', + }; + + const decodedBody = assertDecode(t.exact(t.type(ShareWalletBody)), validBody); + assert.ok(decodedBody); + assert.strictEqual(decodedBody.email, 'test@example.com'); + assert.strictEqual(decodedBody.permissions, 'view,spend'); + }); + + it('should validate ShareWalletBody with optional fields', function () { + const validBody = { + email: 'test@example.com', + permissions: 'view', + walletPassphrase: 'myPassphrase', + message: 'Test message', + reshare: false, + skipKeychain: true, + disableEmail: false, + }; + + const decodedBody = assertDecode(t.exact(t.type(ShareWalletBody)), validBody); + assert.ok(decodedBody); + assert.strictEqual(decodedBody.walletPassphrase, 'myPassphrase'); + assert.strictEqual(decodedBody.message, 'Test message'); + assert.strictEqual(decodedBody.reshare, false); + assert.strictEqual(decodedBody.skipKeychain, true); + assert.strictEqual(decodedBody.disableEmail, false); + }); + + it('should validate successful response (200)', function () { + const validResponse = { + id: 'share123', + coin: 'tbtc', + wallet: '59cd72485007a239fb00282ed480da1f', + fromUser: 'user1', + toUser: 'user2', + permissions: 'view,spend', + }; + + const decodedResponse = assertDecode(ShareWalletResponse[200], validResponse); + assert.ok(decodedResponse); + assert.strictEqual(decodedResponse.id, 'share123'); + assert.strictEqual(decodedResponse.wallet, '59cd72485007a239fb00282ed480da1f'); + }); + + it('should validate response with optional keychain field', function () { + const validResponse = { + id: 'share123', + coin: 'tbtc', + wallet: '59cd72485007a239fb00282ed480da1f', + fromUser: 'user1', + toUser: 'user2', + permissions: 'view,spend', + keychain: { + pub: 'xpub123', + encryptedPrv: 'encrypted', + fromPubKey: 'from', + toPubKey: 'to', + path: 'm', + }, + }; + + const decodedResponse = assertDecode(ShareWalletResponse[200], validResponse); + assert.ok(decodedResponse); + assert.ok(decodedResponse.keychain); + }); + }); + + describe('Route Definition', function () { + it('should have correct route configuration', function () { + assert.strictEqual(PostShareWallet.method, 'POST'); + assert.strictEqual(PostShareWallet.path, '/api/v2/{coin}/wallet/{id}/share'); + assert.ok(PostShareWallet.request); + assert.ok(PostShareWallet.response); + }); + + it('should have correct response types', function () { + assert.ok(PostShareWallet.response[200]); + assert.ok(PostShareWallet.response[400]); + }); + }); +});