diff --git a/modules/bitgo/test/v2/unit/wallets.ts b/modules/bitgo/test/v2/unit/wallets.ts index c9e2c3999f..faeb536d5b 100644 --- a/modules/bitgo/test/v2/unit/wallets.ts +++ b/modules/bitgo/test/v2/unit/wallets.ts @@ -4440,6 +4440,34 @@ describe('V2 Wallets:', function () { }); }); + describe('cancelShare', function () { + it('should send DELETE to /walletshare/:id and return the result', async function () { + const bitgo = TestBitGo.decorate(BitGo, { env: 'mock' }); + bitgo.initializeTestVars(); + const basecoin = bitgo.coin('tbtc'); + const wallets = basecoin.wallets(); + const bgUrl = common.Environments[bitgo.getEnv()].uri; + const shareId = 'abc123shareId'; + + nock(bgUrl) + .delete(`/api/v2/tbtc/walletshare/${shareId}`) + .reply(200, { changed: true, state: 'canceled' }); + + const result = await wallets.cancelShare({ walletShareId: shareId }); + result.should.have.property('changed', true); + result.should.have.property('state', 'canceled'); + }); + + it('should throw if walletShareId is missing', async function () { + const bitgo = TestBitGo.decorate(BitGo, { env: 'mock' }); + bitgo.initializeTestVars(); + const basecoin = bitgo.coin('tbtc'); + const wallets = basecoin.wallets(); + + await wallets.cancelShare({}).should.be.rejectedWith('walletShareId must be a string'); + }); + }); + describe('List Wallets:', function () { it('should list wallets with skipReceiveAddress = true', async function () { const bitgo = TestBitGo.decorate(BitGo, { env: 'mock' }); diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index dd5fdc99a2..dc36b9195e 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -862,6 +862,15 @@ async function handleV2AcceptWalletShare(req: express.Request) { return coin.wallets().acceptShare(params); } +/** + * handle cancel wallet share + */ +export async function handleV2CancelWalletShare(req: ExpressApiRouteRequest<'express.wallet.cancelShare', 'delete'>) { + const bitgo = req.bitgo; + const coin = bitgo.coin(req.decoded.coin); + return coin.wallets().cancelShare({ walletShareId: req.decoded.id }); +} + /** * handle wallet sign transaction */ @@ -2060,6 +2069,7 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { router.post('express.v2.address.derive', [prepareBitGo(config), typedPromiseWrapper(handleV2DeriveAddress)]); router.post('express.wallet.share', [prepareBitGo(config), typedPromiseWrapper(handleV2ShareWallet)]); + router.delete('express.wallet.cancelShare', [prepareBitGo(config), typedPromiseWrapper(handleV2CancelWalletShare)]); 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 85adc8a2ea..83d694f525 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -40,6 +40,7 @@ import { PostCoinSignTx } from './v2/coinSignTx'; import { PostWalletSignTx } from './v2/walletSignTx'; import { PostWalletTxSignTSS } from './v2/walletTxSignTSS'; import { PostShareWallet } from './v2/shareWallet'; +import { DeleteCancelWalletShare } from './v2/cancelWalletShare'; import { PutExpressWalletUpdate } from './v2/expressWalletUpdate'; import { PostFanoutUnspents } from './v2/fanoutUnspents'; import { PostSendMany } from './v2/sendmany'; @@ -345,6 +346,9 @@ export const ExpressWalletManagementApiSpec = apiSpec({ 'express.wallet.share': { post: PostShareWallet, }, + 'express.wallet.cancelShare': { + delete: DeleteCancelWalletShare, + }, 'express.wallet.update': { put: PutExpressWalletUpdate, }, diff --git a/modules/express/src/typedRoutes/api/v2/cancelWalletShare.ts b/modules/express/src/typedRoutes/api/v2/cancelWalletShare.ts new file mode 100644 index 0000000000..4f8d03ce34 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/cancelWalletShare.ts @@ -0,0 +1,47 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; +import { ShareState } from '../../schemas/wallet'; + +/** + * Path parameters for canceling a wallet share + */ +export const CancelWalletShareParams = { + /** A cryptocurrency or token ticker symbol. */ + coin: t.string, + /** The wallet share ID to cancel. */ + id: t.string, +} as const; + +/** + * Response for canceling a wallet share + */ +export const CancelWalletShareResponse200 = t.type({ + /** Whether the share state was changed by this operation. */ + changed: t.boolean, + /** Current state of the wallet share after the operation. */ + state: ShareState, +}); + +export const CancelWalletShareResponse = { + 200: CancelWalletShareResponse200, + 400: BitgoExpressError, +} as const; + +/** + * Cancel a pending wallet share invitation + * + * Cancels an outgoing wallet share that has not yet been accepted. + * Only the user who created the share can cancel it. + * + * @operationId express.wallet.cancelShare + * @tag Express + */ +export const DeleteCancelWalletShare = httpRoute({ + path: '/api/v2/{coin}/walletshare/{id}', + method: 'DELETE', + request: httpRequest({ + params: CancelWalletShareParams, + }), + response: CancelWalletShareResponse, +}); diff --git a/modules/express/test/unit/clientRoutes/cancelWalletShare.ts b/modules/express/test/unit/clientRoutes/cancelWalletShare.ts new file mode 100644 index 0000000000..8a0e969e20 --- /dev/null +++ b/modules/express/test/unit/clientRoutes/cancelWalletShare.ts @@ -0,0 +1,91 @@ +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, decodeOrElse, common } from '@bitgo/sdk-core'; +import { ExpressApiRouteRequest } from '../../../src/typedRoutes/api'; +import { handleV2CancelWalletShare } from '../../../src/clientRoutes'; +import { CancelWalletShareResponse } from '../../../src/typedRoutes/api/v2/cancelWalletShare'; + +describe('Cancel Wallet Share (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 cancelShare and return a typed response', async () => { + const coin = 'tbtc'; + const shareId = 'abc123shareId'; + + const cancelResponse = { + changed: true, + state: 'canceled', + }; + + const cancelShareStub = sinon.stub().resolves(cancelResponse); + const coinStub = sinon.createStubInstance(BaseCoin, { + wallets: sinon.stub<[], Wallets>().returns({ + cancelShare: cancelShareStub, + } as any), + }); + + const stubBitgo = sinon.createStubInstance(BitGo, { coin: sinon.stub<[string]>().returns(coinStub) }); + + const req = { + bitgo: stubBitgo, + decoded: { + coin, + id: shareId, + }, + } as unknown as ExpressApiRouteRequest<'express.wallet.cancelShare', 'delete'>; + + const res = await handleV2CancelWalletShare(req); + + cancelShareStub.calledOnceWith({ walletShareId: shareId }).should.be.true(); + decodeOrElse('CancelWalletShareResponse200', CancelWalletShareResponse[200], res, (errors) => { + throw new Error(`Response did not match expected codec: ${errors}`); + }); + }); + + it('should pass the walletShareId from the route param to cancelShare', async () => { + const coin = 'tbtc'; + const shareId = 'someOtherShareId'; + + const cancelShareStub = sinon.stub().resolves({ changed: false, state: 'canceled' }); + const coinStub = sinon.createStubInstance(BaseCoin, { + wallets: sinon.stub<[], Wallets>().returns({ + cancelShare: cancelShareStub, + } as any), + }); + + const stubBitgo = sinon.createStubInstance(BitGo, { coin: sinon.stub<[string]>().returns(coinStub) }); + + const req = { + bitgo: stubBitgo, + decoded: { + coin, + id: shareId, + }, + } as unknown as ExpressApiRouteRequest<'express.wallet.cancelShare', 'delete'>; + + await handleV2CancelWalletShare(req); + + cancelShareStub.calledOnceWith({ walletShareId: shareId }).should.be.true(); + }); +});