diff --git a/apps/extension/src/background/approvals/service.test.ts b/apps/extension/src/background/approvals/service.test.ts index 35b5c5f519..f549936944 100644 --- a/apps/extension/src/background/approvals/service.test.ts +++ b/apps/extension/src/background/approvals/service.test.ts @@ -12,29 +12,41 @@ import { TokenInfo, TransferMsgValue, } from "@namada/types"; +import { paramsToUrl } from "@namada/utils"; import { KeyRingService, TabStore } from "background/keyring"; import { LedgerService } from "background/ledger"; import { VaultService } from "background/vault"; import BigNumber from "bignumber.js"; import createMockInstance from "jest-create-mock-instance"; import { KVStoreMock } from "test/init"; +import * as webextensionPolyfill from "webextension-polyfill"; import { ApprovalsService } from "./service"; import { ApprovedOriginsStore, TxStore } from "./types"; +import * as utils from "./utils"; jest.mock("webextension-polyfill", () => ({ runtime: { getURL: () => "url", }, windows: { - create: jest.fn(), + create: jest.fn().mockResolvedValue({ tabs: [{ id: 1 }] }), }, })); -describe.only("approvals service", () => { +jest.mock("@namada/utils", () => { + return { + ...jest.requireActual("@namada/utils"), + paramsToUrl: jest.fn(), + __esModule: true, + }; +}); + +describe("approvals service", () => { let service: ApprovalsService; let keyRingService: jest.Mocked; let dataStore: KVStoreMock; let txStore: KVStoreMock; + let approvedOriginsStore: KVStoreMock; afterEach(() => { jest.restoreAllMocks(); @@ -45,7 +57,7 @@ describe.only("approvals service", () => { txStore = new KVStoreMock("TxStore"); dataStore = new KVStoreMock("DataStore"); const connectedTabsStore = new KVStoreMock("TabStore"); - const approvedOriginsStore = new KVStoreMock( + approvedOriginsStore = new KVStoreMock( "ApprovedOriginsStore" ); keyRingService = createMockInstance(KeyRingService as any); @@ -75,7 +87,6 @@ describe.only("approvals service", () => { signature: "sig", }; - jest.spyOn(service as any, "getPopupTabId").mockResolvedValue(tabId); const signaturePromise = service.approveSignature("signer", "data"); await new Promise((resolve) => @@ -91,7 +102,9 @@ describe.only("approvals service", () => { }); it("should throw an error when popupTabId is not present", async () => { - jest.spyOn(service as any, "getPopupTabId").mockResolvedValue(undefined); + (webextensionPolyfill.windows.create as any).mockResolvedValue({ + tabs: [], + }); expect(service.approveSignature("signer", "data")).rejects.toBeDefined(); }); @@ -102,7 +115,9 @@ describe.only("approvals service", () => { hash: "hash", signature: "sig", }; - jest.spyOn(service as any, "getPopupTabId").mockResolvedValue(tabId); + (webextensionPolyfill.windows.create as any).mockResolvedValue({ + tabs: [{ id: tabId }], + }); (service as any).resolverMap[tabId] = sigResponse; expect(service.approveSignature("signer", "data")).rejects.toBeDefined(); @@ -117,7 +132,9 @@ describe.only("approvals service", () => { signature: "sig", }; - jest.spyOn(service as any, "getPopupTabId").mockResolvedValue(tabId); + (webextensionPolyfill.windows.create as any).mockResolvedValue({ + tabs: [{ id: tabId }], + }); jest.spyOn(dataStore, "get").mockResolvedValueOnce("data"); jest .spyOn(keyRingService, "signArbitrary") @@ -162,34 +179,35 @@ describe.only("approvals service", () => { it("should reject promise if can't sign", async () => { const tabId = 1; + const error = "Can't sign"; - jest.spyOn(service as any, "getPopupTabId").mockResolvedValue(tabId); + service["resolverMap"] = { + [tabId]: { resolve: jest.fn(), reject: jest.fn() }, + }; + (webextensionPolyfill.windows.create as any).mockResolvedValue({ + tabs: [{ id: tabId }], + }); jest.spyOn(dataStore, "get").mockResolvedValueOnce("data"); - jest - .spyOn(keyRingService, "signArbitrary") - .mockRejectedValue("Can't sign"); - const signer = "signer"; + jest.spyOn(keyRingService, "signArbitrary").mockRejectedValue(error); - await new Promise((resolve) => - setTimeout(() => { - resolve(true); - }) - ); + const signer = "signer"; - expect( - service.submitSignature(tabId, "msgId", signer) - ).rejects.toBeDefined(); + const res = await service.submitSignature(tabId, "msgId", signer); + expect(res).toBeUndefined(); + expect(service["resolverMap"][tabId].reject).toHaveBeenCalledWith(error); }); }); describe("submitSignature", () => { it("should reject resolver", async () => { const tabId = 1; - - jest.spyOn(service as any, "getPopupTabId").mockResolvedValue(tabId); const signer = "signer"; const signaturePromise = service.approveSignature(signer, "data"); + (webextensionPolyfill.windows.create as any).mockResolvedValue({ + tabs: [{ id: tabId }], + }); + await new Promise((resolve) => setTimeout(() => { resolve(true); @@ -218,7 +236,7 @@ describe.only("approvals service", () => { ] as const; describe("approveTx", () => { - it.each(txTypes)("should launch tx", async (type, paramsFn) => { + it.each(txTypes)("%i txType fn: %s", async (type, paramsFn) => { jest.spyOn(ApprovalsService, paramsFn).mockImplementation(() => ({})); jest.spyOn(borsh, "deserialize").mockReturnValue({}); jest.spyOn(service as any, "_launchApprovalWindow"); @@ -227,6 +245,15 @@ describe.only("approvals service", () => { service.approveTx(type, "", "", AccountType.Mnemonic) ).resolves.not.toBeDefined(); }); + + it("should throw an error if txType is not found", async () => { + const type: any = 999; + jest.spyOn(borsh, "deserialize").mockReturnValue({}); + + expect( + service.approveTx(type, "", "", AccountType.Mnemonic) + ).rejects.toBeDefined(); + }); }); describe("getParamsTransfer", () => { @@ -485,4 +512,324 @@ describe.only("approvals service", () => { expect((service as any)._clearPendingTx).toHaveBeenCalledWith("msgId"); }); }); + + const submitTxTypes = [ + [TxType.Bond, "submitBond"], + [TxType.Unbond, "submitUnbond"], + [TxType.Withdraw, "submitWithdraw"], + [TxType.Transfer, "submitTransfer"], + [TxType.IBCTransfer, "submitIbcTransfer"], + [TxType.EthBridgeTransfer, "submitEthBridgeTransfer"], + [TxType.VoteProposal, "submitVoteProposal"], + ] as const; + + describe("submitTx", () => { + it.each(submitTxTypes)("%i txType fn: %s", async (txType, paramsFn) => { + const msgId = "msgId"; + const txMsg = "txMsg"; + const specificMsg = "specificMsg"; + + jest.spyOn(service["txStore"], "get").mockImplementation(() => { + return Promise.resolve({ + txType, + txMsg, + specificMsg, + }); + }); + + jest.spyOn(keyRingService, paramsFn).mockResolvedValue(); + jest.spyOn(service as any, "_clearPendingTx"); + + await service.submitTx(msgId); + expect(service["_clearPendingTx"]).toHaveBeenCalledWith(msgId); + expect(keyRingService[paramsFn]).toHaveBeenCalledWith( + specificMsg, + txMsg, + msgId + ); + }); + + it("should throw an error if txType is not found", async () => { + const msgId = "msgId"; + const txMsg = "txMsg"; + const specificMsg = "specificMsg"; + const txType: any = 999; + + jest.spyOn(service["txStore"], "get").mockImplementation(() => { + return Promise.resolve({ + txType, + txMsg, + specificMsg, + }); + }); + + expect(service.submitTx(msgId)).rejects.toBeDefined(); + }); + + it("should throw an error if tx is not found", async () => { + jest.spyOn(service["txStore"], "get").mockImplementation(() => { + return Promise.resolve(undefined); + }); + + expect(service.submitTx("msgId")).rejects.toBeDefined(); + }); + }); + + describe("approveConnection", () => { + it("should approve connection if it's not already approved", async () => { + const url = "url-with-params"; + const interfaceTabId = 999; + const interfaceOrigin = "origin"; + const tabId = 1; + + (paramsToUrl as any).mockImplementation(() => url); + jest.spyOn(approvedOriginsStore, "get").mockResolvedValue(undefined); + jest.spyOn(service as any, "_launchApprovalWindow").mockResolvedValue({ + tabs: [{ id: tabId }], + }); + service["resolverMap"] = {}; + + const promise = service.approveConnection( + interfaceTabId, + interfaceOrigin + ); + await new Promise((r) => + setTimeout(() => { + r(); + }) + ); + service["resolverMap"][tabId]?.resolve(true); + + expect(paramsToUrl).toHaveBeenCalledWith("url#/approve-connection", { + interfaceTabId: interfaceTabId.toString(), + interfaceOrigin, + }); + expect(approvedOriginsStore.get).toHaveBeenCalledWith( + utils.APPROVED_ORIGINS_KEY + ); + expect(service["_launchApprovalWindow"]).toHaveBeenCalledWith(url); + expect(promise).resolves.toBeDefined(); + }); + + it("should not approve connection if it was already approved", async () => { + const url = "url-with-params"; + const interfaceTabId = 999; + const interfaceOrigin = "origin"; + (paramsToUrl as any).mockImplementation(() => url); + jest + .spyOn(approvedOriginsStore, "get") + .mockResolvedValue([interfaceOrigin]); + + expect( + service.approveConnection(interfaceTabId, interfaceOrigin) + ).resolves.toBeUndefined(); + }); + + it("should throw an error when popupTabId is not found", async () => { + const url = "url-with-params"; + const interfaceTabId = 999; + const interfaceOrigin = "origin"; + const approvedOrigins = ["other-origin"]; + + (paramsToUrl as any).mockImplementation(() => url); + jest + .spyOn(approvedOriginsStore, "get") + .mockResolvedValue(approvedOrigins); + jest.spyOn(service as any, "_launchApprovalWindow").mockResolvedValue({ + tabs: [], + }); + + expect( + service.approveConnection(interfaceTabId, interfaceOrigin) + ).rejects.toBeDefined(); + }); + + it("should throw an error when popupTabId is found in resolverMap", async () => { + const url = "url-with-params"; + const interfaceTabId = 999; + const interfaceOrigin = "origin"; + const approvedOrigins = ["other-origin"]; + const tabId = 1; + + service["resolverMap"] = { + [tabId]: { + resolve: jest.fn(), + reject: jest.fn(), + }, + }; + + (paramsToUrl as any).mockImplementation(() => url); + jest + .spyOn(approvedOriginsStore, "get") + .mockResolvedValue(approvedOrigins); + jest.spyOn(service as any, "_launchApprovalWindow").mockResolvedValue({ + tabs: [{ id: tabId }], + }); + + expect( + service.approveConnection(interfaceTabId, interfaceOrigin) + ).rejects.toBeDefined(); + }); + }); + + describe("approveConnectionResponse", () => { + it("should approve connection response", async () => { + const interfaceTabId = 999; + const interfaceOrigin = "origin"; + const popupTabId = 1; + service["resolverMap"] = { + [popupTabId]: { + resolve: jest.fn(), + reject: jest.fn(), + }, + }; + jest.spyOn(keyRingService, "connect").mockResolvedValue(); + jest.spyOn(utils, "addApprovedOrigin").mockResolvedValue(); + + await service.approveConnectionResponse( + interfaceTabId, + interfaceOrigin, + true, + popupTabId + ); + + expect(service["resolverMap"][popupTabId].resolve).toHaveBeenCalled(); + expect(keyRingService.connect).toHaveBeenCalledWith(interfaceTabId); + expect(utils.addApprovedOrigin).toHaveBeenCalledWith( + approvedOriginsStore, + interfaceOrigin + ); + }); + + it("should throw an error if resolvers are not found", async () => { + const interfaceTabId = 999; + const interfaceOrigin = "origin"; + const popupTabId = 1; + + expect( + service.approveConnectionResponse( + interfaceTabId, + interfaceOrigin, + true, + popupTabId + ) + ).rejects.toBeDefined(); + }); + + it("should reject the connection if allowConnection is set to false", async () => { + const interfaceTabId = 999; + const interfaceOrigin = "origin"; + const popupTabId = 1; + service["resolverMap"] = { + [popupTabId]: { + resolve: jest.fn(), + reject: jest.fn(), + }, + }; + + await service.approveConnectionResponse( + interfaceTabId, + interfaceOrigin, + false, + popupTabId + ); + + expect(service["resolverMap"][popupTabId].reject).toHaveBeenCalled(); + }); + + it("should reject the key ring can't connect", async () => { + const interfaceTabId = 999; + const interfaceOrigin = "origin"; + const popupTabId = 1; + service["resolverMap"] = { + [popupTabId]: { + resolve: jest.fn(), + reject: jest.fn(), + }, + }; + jest.spyOn(keyRingService, "connect").mockRejectedValue("Can't connect"); + + await service.approveConnectionResponse( + interfaceTabId, + interfaceOrigin, + true, + popupTabId + ); + + expect(service["resolverMap"][popupTabId].reject).toHaveBeenCalled(); + }); + }); + + describe("revokeConnection", () => { + it("should reject connection response", async () => { + const originToRevoke = "origin"; + + jest.spyOn(utils, "removeApprovedOrigin").mockResolvedValue(); + await service.revokeConnection(originToRevoke); + + expect(utils.removeApprovedOrigin).toHaveBeenCalledWith( + service["approvedOriginsStore"], + originToRevoke + ); + }); + }); + + describe("getPopupTabId", () => { + it("should return tab id", async () => { + (webextensionPolyfill.windows.create as any).mockResolvedValue({ + tabs: [{ id: 1 }], + }); + + expect((service as any).getPopupTabId("url")).resolves.toBe(1); + }); + + it("should return undefined if tabs are undefined", async () => { + (webextensionPolyfill.windows.create as any).mockResolvedValue({}); + + expect((service as any).getPopupTabId("url")).resolves.toBeUndefined(); + }); + + it("should return undefined if tabs are empty", async () => { + (webextensionPolyfill.windows.create as any).mockResolvedValue({ + tabs: [], + }); + + expect((service as any).getPopupTabId("url")).resolves.toBeUndefined(); + }); + }); + + describe("getTxDetails", () => { + it("should return tx details", () => { + const txMsgValue = { + token: "token", + feeAmount: BigNumber(0.5), + gasLimit: BigNumber(0.5), + chainId: "chainId", + publicKey: "publicKey", + }; + + const { publicKey, nativeToken } = (ApprovalsService as any).getTxDetails( + txMsgValue + ); + + expect(publicKey).toEqual(txMsgValue.publicKey); + expect(nativeToken).toEqual(txMsgValue.token); + }); + + it("should return tx details with empty publicKey when missing", () => { + const txMsgValue = { + token: "token", + feeAmount: BigNumber(0.5), + gasLimit: BigNumber(0.5), + chainId: "chainId", + }; + + const { publicKey, nativeToken } = (ApprovalsService as any).getTxDetails( + txMsgValue + ); + + expect(publicKey).toEqual(""); + expect(nativeToken).toEqual(txMsgValue.token); + }); + }); }); diff --git a/apps/extension/src/background/approvals/service.ts b/apps/extension/src/background/approvals/service.ts index 03eff62b7e..f69eccf8e6 100644 --- a/apps/extension/src/background/approvals/service.ts +++ b/apps/extension/src/background/approvals/service.ts @@ -179,7 +179,7 @@ export class ApprovalsService { } = specificDetails; const amount = new BigNumber(amountBN.toString()); - const { publicKey = "", token: nativeToken } = txDetails; + const { publicKey, nativeToken } = ApprovalsService.getTxDetails(txDetails); return { source, @@ -202,7 +202,7 @@ export class ApprovalsService { } = specificDetails; const amount = new BigNumber(amountBN.toString()); - const { publicKey = "", token: nativeToken } = txDetails; + const { publicKey, nativeToken } = ApprovalsService.getTxDetails(txDetails); return { source, @@ -224,7 +224,7 @@ export class ApprovalsService { amount, } = specificDetails; - const { publicKey = "", token: nativeToken } = txDetails; + const { publicKey, nativeToken } = ApprovalsService.getTxDetails(txDetails); return { source, @@ -246,7 +246,7 @@ export class ApprovalsService { } = specificDetails; const amount = new BigNumber(amountBN.toString()); - const { publicKey = "" } = txDetails; + const { publicKey } = ApprovalsService.getTxDetails(txDetails); return { source, @@ -263,7 +263,7 @@ export class ApprovalsService { const { source, amount: amountBN } = specificDetails; const amount = new BigNumber(amountBN.toString()); - const { publicKey = "", token: nativeToken } = txDetails; + const { publicKey, nativeToken } = ApprovalsService.getTxDetails(txDetails); return { source, @@ -278,7 +278,7 @@ export class ApprovalsService { const { source, validator } = specificDetails; - const { publicKey = "", token: nativeToken } = txDetails; + const { publicKey, nativeToken } = ApprovalsService.getTxDetails(txDetails); return { source, @@ -296,7 +296,7 @@ export class ApprovalsService { const { signer } = specificDetails; - const { publicKey = "", token: nativeToken } = txDetails; + const { publicKey, nativeToken } = ApprovalsService.getTxDetails(txDetails); //TODO: check this return { @@ -361,8 +361,7 @@ export class ApprovalsService { (await this.approvedOriginsStore.get(APPROVED_ORIGINS_KEY)) || []; if (!approvedOrigins.includes(interfaceOrigin)) { - const approvalWindow = await this._launchApprovalWindow(url); - const popupTabId = approvalWindow.tabs?.[0]?.id; + const popupTabId = await this.getPopupTabId(url); if (!popupTabId) { throw new Error("no popup tab ID"); @@ -425,8 +424,16 @@ export class ApprovalsService { private getPopupTabId = async (url: string): Promise => { const window = await this._launchApprovalWindow(url); - const popupTabId = window.tabs?.[0]?.id; + const firstTab = window.tabs?.[0]; + const popupTabId = firstTab?.id; return popupTabId; }; + + private static getTxDetails = ( + txDetails: TxMsgValue + ): { publicKey: string; nativeToken: string } => { + const { publicKey = "", token: nativeToken } = txDetails; + return { publicKey, nativeToken }; + }; }