diff --git a/modules/bitgo/test/v2/unit/pendingApproval.ts b/modules/bitgo/test/v2/unit/pendingApproval.ts index 8a52608dc7..270b1851a0 100644 --- a/modules/bitgo/test/v2/unit/pendingApproval.ts +++ b/modules/bitgo/test/v2/unit/pendingApproval.ts @@ -174,6 +174,23 @@ describe('Pending Approvals:', () => { paScope.isDone().should.be.true(); }); + it('should cancel a pending approval without approving or rejecting it', async () => { + const pendingApproval = new PendingApproval(bitgo, basecoin, pendingApprovalData, wallet); + const paScope = nock(bgUrl) + .put(`/api/v2/${coin}/pendingapprovals/${pendingApprovalData.id}`, { + state: 'canceled', + }) + .reply(200, { + ...pendingApprovalData, + state: State.CANCELED, + }); + + const response = await pendingApproval.cancel(); + + response.state.should.equal(State.CANCELED); + paScope.isDone().should.be.true(); + }); + function testRecreateTransaction(coinName: string, recreateTransaction: boolean, type: Type) { it(`[${coinName}] should ${ recreateTransaction ? 'not ' : '' diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index bdcad288ab..6697b88cc3 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -263,6 +263,9 @@ function handleApproveTransaction(req: ExpressApiRouteRequest<'express.v1.pendin if (params.state === 'approved') { return pendingApproval.approve(params); } + if (params.state === 'canceled') { + return pendingApproval.cancel(params); + } return pendingApproval.reject(params); }); } @@ -833,6 +836,9 @@ async function handleV2PendingApproval(req: ExpressApiRouteRequest<'express.pend if (params.state === 'approved') { return pendingApproval.approve(params); } + if (params.state === 'canceled') { + return pendingApproval.cancel(params); + } return pendingApproval.reject(params); } diff --git a/modules/express/src/typedRoutes/api/v1/pendingApproval.ts b/modules/express/src/typedRoutes/api/v1/pendingApproval.ts index edc26ad7fd..bc2ad8bde1 100644 --- a/modules/express/src/typedRoutes/api/v1/pendingApproval.ts +++ b/modules/express/src/typedRoutes/api/v1/pendingApproval.ts @@ -9,7 +9,7 @@ export const pendingApprovalRequestParams = { }; export const pendingApprovalRequestBody = { - /** State of the approval: 'approved' to approve, any other value or omit to reject (defaults to rejection) */ + /** State of the approval: 'approved' to approve, 'canceled' to cancel, any other value or omit to reject */ state: optional(t.string), /** Wallet passphrase for transaction signing (required for transactionRequest approvals unless tx or xprv provided) */ walletPassphrase: optional(t.string), @@ -30,9 +30,9 @@ export const pendingApprovalRequestBody = { /** * Approve or reject a pending approval * - * Updates the state of a pending approval to either 'approved' or 'rejected' based on the - * state parameter. If state is 'approved', the approval is approved; otherwise, it is rejected. - * Default behavior (when state is omitted) is rejection. + * Updates the state of a pending approval to 'approved', 'canceled', or 'rejected' based on + * the state parameter. If state is 'approved', the approval is approved; if state is 'canceled', + * it is canceled; otherwise, it is rejected. Default behavior is rejection. * * **Approval Types:** * - **transactionRequest**: Transaction approvals (may require walletPassphrase for signing) diff --git a/modules/express/src/typedRoutes/api/v2/pendingApproval.ts b/modules/express/src/typedRoutes/api/v2/pendingApproval.ts index e4325ccf0a..f786d1629a 100644 --- a/modules/express/src/typedRoutes/api/v2/pendingApproval.ts +++ b/modules/express/src/typedRoutes/api/v2/pendingApproval.ts @@ -17,7 +17,7 @@ export const PendingApprovalParams = { * Request body for approving or rejecting a pending approval */ export const PendingApprovalRequestBody = { - /** New state for the pending approval: 'approved' to approve, omit or 'rejected' to reject */ + /** New state: 'approved' to approve, 'canceled' to cancel, omit or 'rejected' to reject */ state: optional(t.string), /** Passphrase to decrypt the user key on the wallet */ walletPassphrase: optional(t.string), @@ -48,6 +48,7 @@ export const PendingApprovalState = t.union([ t.literal('approved'), t.literal('processing'), t.literal('rejected'), + t.literal('canceled'), ]); /** diff --git a/modules/express/test/unit/typedRoutes/pendingApproval.ts b/modules/express/test/unit/typedRoutes/pendingApproval.ts index b2b6585556..15e683ac5f 100644 --- a/modules/express/test/unit/typedRoutes/pendingApproval.ts +++ b/modules/express/test/unit/typedRoutes/pendingApproval.ts @@ -321,6 +321,13 @@ describe('PendingApproval codec tests', function () { enterprise: 'enterprise123', }; + const mockCanceledResponse = { + id: 'approval123', + state: 'canceled', + wallet: 'wallet123', + enterprise: 'enterprise123', + }; + afterEach(function () { sinon.restore(); }); @@ -387,6 +394,32 @@ describe('PendingApproval codec tests', function () { assert.strictEqual(result.body.enterprise, mockRejectedResponse.enterprise); }); + it('should successfully cancel pending approval', async function () { + const approvalId = '123456789abcdef'; + const requestBody = { state: 'canceled' }; + const cancel = sinon.stub().resolves(mockCanceledResponse); + + const mockPendingApprovalObject = { + approve: sinon.stub().resolves(mockApprovedResponse), + reject: sinon.stub().resolves(mockRejectedResponse), + cancel, + }; + + sinon.stub(BitGo.prototype, 'pendingApprovals').returns({ + get: sinon.stub().resolves(mockPendingApprovalObject), + } as any); + + const result = await agent + .put(`/api/v1/pendingapprovals/${approvalId}/express`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + assert.strictEqual(result.body.state, 'canceled'); + sinon.assert.calledOnce(cancel); + }); + it('should successfully approve with otp', async function () { const approvalId = '123456789abcdef'; const requestBody = { diff --git a/modules/express/test/unit/typedRoutes/pendingApprovalV2.ts b/modules/express/test/unit/typedRoutes/pendingApprovalV2.ts index 5ba1d38910..0084aa6225 100644 --- a/modules/express/test/unit/typedRoutes/pendingApprovalV2.ts +++ b/modules/express/test/unit/typedRoutes/pendingApprovalV2.ts @@ -321,6 +321,14 @@ describe('V2 PendingApproval API Tests', function () { wallet: 'wallet123', }; + const mockCanceledResponse = { + id: 'approval123', + state: 'canceled', + creator: 'user123', + info: { type: 'transactionRequest' }, + wallet: 'wallet123', + }; + afterEach(function () { sinon.restore(); }); @@ -389,6 +397,36 @@ describe('V2 PendingApproval API Tests', function () { sinon.assert.calledOnce(mockPendingApprovalObject.reject); }); + it('should successfully cancel pending approval', async function () { + const coin = 'tbtc'; + const approvalId = '123456789abcdef'; + const cancel = sinon.stub().resolves(mockCanceledResponse); + + const mockPendingApprovalObject = { + approve: sinon.stub().resolves(mockApprovedResponse), + reject: sinon.stub().resolves(mockRejectedResponse), + cancel, + }; + + const mockCoin = { + pendingApprovals: sinon.stub().returns({ + get: sinon.stub().resolves(mockPendingApprovalObject), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .put(`/api/v2/${coin}/pendingapprovals/${approvalId}`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send({ state: 'canceled' }); + + assert.strictEqual(result.status, 200); + assert.strictEqual(result.body.state, 'canceled'); + sinon.assert.calledOnce(cancel); + }); + it('should default to rejection with empty body', async function () { const coin = 'tbtc'; const approvalId = '123456789abcdef'; diff --git a/modules/sdk-api/src/v1/pendingapproval.ts b/modules/sdk-api/src/v1/pendingapproval.ts index daff3f1c66..49aa7777cf 100644 --- a/modules/sdk-api/src/v1/pendingapproval.ts +++ b/modules/sdk-api/src/v1/pendingapproval.ts @@ -359,10 +359,12 @@ PendingApproval.prototype.reject = function (params, callback) { // // cancel -// rejects the pending approval +// cancels the pending approval // PendingApproval.prototype.cancel = function (params, callback) { - return this.reject(params, callback); + return Promise.resolve(this.bitgo.put(this.url()).send({ state: 'canceled' }).result()) + .then(callback) + .catch(callback); }; export = PendingApproval; diff --git a/modules/sdk-core/src/bitgo/pendingApproval/iPendingApproval.ts b/modules/sdk-core/src/bitgo/pendingApproval/iPendingApproval.ts index a28396b052..24a83e8f73 100644 --- a/modules/sdk-core/src/bitgo/pendingApproval/iPendingApproval.ts +++ b/modules/sdk-core/src/bitgo/pendingApproval/iPendingApproval.ts @@ -16,6 +16,7 @@ export enum State { APPROVED = 'approved', PROCESSING = 'processing', REJECTED = 'rejected', + CANCELED = 'canceled', } export enum Type { diff --git a/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts b/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts index cb867e3c1e..ab7c2b3ea3 100644 --- a/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts +++ b/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts @@ -225,13 +225,11 @@ export class PendingApproval implements IPendingApproval { } /** - * Alias for PendingApproval.reject() - * - * @deprecated + * Sets this PendingApproval to a canceled state. * @param params */ async cancel(params: Record = {}): Promise { - return await this.reject(params); + return await this.bitgo.put(this.url()).send({ state: 'canceled' }).result(); } /**