diff --git a/package.json b/package.json index 9e12c0d4..bbea1463 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "devDependencies": { "@simbathesailor/use-what-changed": "^2.0.0", "@testing-library/jest-dom": "^5.15.0", + "@testing-library/react": "^12.1.2", "@typechain/ethers-v5": "^7.1.2", "@types/chai": "^4.2.22", "@types/chai-as-promised": "^7.1.4", diff --git a/src/__tests__/hooks/ens.test.tsx b/src/__tests__/hooks/ens.test.tsx new file mode 100644 index 00000000..e70d334f --- /dev/null +++ b/src/__tests__/hooks/ens.test.tsx @@ -0,0 +1,236 @@ +import { Web3Provider } from "@ethersproject/providers"; +import SafeProvider from "@gnosis.pm/safe-apps-react-sdk"; +import { render, RenderResult, screen } from "@testing-library/react"; +import { ethers } from "ethers"; +import React, { useEffect, useState } from "react"; +import { unmountComponentAtNode } from "react-dom"; +import { act } from "react-dom/test-utils"; + +import { useEnsResolver } from "../../hooks/ens"; +import { sendSafeInfo, setupMocksForSafeProvider } from "../../test/safeUtil"; +import { testData } from "../../test/util"; + +type TestENSComponentProps = { ensNamesToResolve?: string[]; addressesToLookup?: string[] }; +/** + * Small component which executes some hook functions and puts the results in the dom. + */ +const TestENSComponent = (props: TestENSComponentProps): JSX.Element => { + const { ensNamesToResolve, addressesToLookup } = props; + const ensResolver = useEnsResolver(); + const [isEnsEnabled, setIsEnsEnabled] = useState(undefined); + const [resolvedNames, setResolvedNames] = useState | undefined>(undefined); + const [lookedUpAddresses, setLookedUpAddresses] = useState | undefined>(undefined); + + useEffect(() => { + const fetchData = async () => { + ensResolver.isEnsEnabled().then((result) => { + act(() => { + setIsEnsEnabled(result); + }); + }); + if (addressesToLookup) { + const results: Array = []; + for (const address of addressesToLookup) { + results.push(await ensResolver.lookupAddress(address)); + } + setLookedUpAddresses(results); + } + if (ensNamesToResolve) { + const results: Array = []; + for (const name of ensNamesToResolve) { + results.push(await ensResolver.resolveName(name)); + } + setResolvedNames(results); + } + }; + fetchData(); + }, [addressesToLookup, ensNamesToResolve, ensResolver]); + + return ( +
+ {typeof isEnsEnabled !== "undefined" ?
{isEnsEnabled.toString()}
: <>} + {typeof resolvedNames !== "undefined" ? ( + resolvedNames.map((resolvedName, idx) => ( +
+ {resolvedName} +
+ )) + ) : ( + <> + )} + {typeof lookedUpAddresses !== "undefined" ? ( + lookedUpAddresses.map((lookedUpAddress, idx) => ( +
+ {lookedUpAddress} +
+ )) + ) : ( + <> + )} +
+ ); +}; + +const renderTestComponent = (container: HTMLElement, props: TestENSComponentProps = {}) => + render( + loading...}> + + , + { container }, + ); + +let container: HTMLDivElement | null = null; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + setupMocksForSafeProvider(); +}); + +afterEach(() => { + jest.clearAllMocks(); + if (container) { + unmountComponentAtNode(container); + container.remove(); + container = null; + } +}); + +test("isEnsEnabled with ens capable network", async () => { + const fakeWeb3Provider: any = { + getNetwork: () => { + return Promise.resolve({ chainId: 4, network: "rinkeby", ensAddress: "0x00000000000001" }); + }, + }; + + jest.spyOn(ethers.providers, "Web3Provider").mockImplementation(() => fakeWeb3Provider); + let renderedContainer: undefined | RenderResult; + act(() => { + if (container !== null) { + renderedContainer = renderTestComponent(container); + } + }); + + sendSafeInfo(); + + expect(renderedContainer).toBeTruthy(); + const ensEnabledElement = await screen.findByTestId("isEnsEnabled"); + expect(ensEnabledElement?.innerHTML).toBe("true"); +}); + +test("isEnsEnabled with non ens network", async () => { + const fakeWeb3Provider: any = { + getNetwork: () => { + return Promise.resolve({ chainId: 9, network: "randomnetwork" }); + }, + }; + + jest.spyOn(ethers.providers, "Web3Provider").mockImplementation(() => fakeWeb3Provider); + let renderedContainer: undefined | RenderResult; + act(() => { + if (container !== null) { + renderedContainer = renderTestComponent(container); + } + }); + + sendSafeInfo(); + + expect(renderedContainer).toBeTruthy(); + const ensEnabledElement = await screen.findByTestId("isEnsEnabled"); + expect(ensEnabledElement?.innerHTML).toBe("false"); +}); + +/** + * we render the test component twice with the same props and check, that the web3Provider functions get called only once. + */ +test("resolving an address and lookups are cached", async () => { + const resolveName = jest.fn(async (name) => { + if ((await name) === "test.eth") { + return Promise.resolve(testData.addresses.receiver1); + } else { + return Promise.resolve(null); + } + }); + const lookupAddress = jest.fn(async (address) => { + if ((await address) === testData.addresses.receiver1) { + return Promise.resolve("test.eth"); + } else { + return address; + } + }); + const fakeWeb3Provider: Partial = { + getNetwork: () => + Promise.resolve({ chainId: 4, network: "rinkeby", _defaultProvider: () => null, name: "rinkeby" }), + resolveName: (name) => resolveName(name), + lookupAddress: (address) => lookupAddress(address), + }; + + jest.spyOn(ethers.providers, "Web3Provider").mockImplementation(() => fakeWeb3Provider as any); + let renderedContainer; + act(() => { + if (container !== null) { + renderedContainer = renderTestComponent(container, { + addressesToLookup: [testData.addresses.receiver1, testData.addresses.receiver1], + ensNamesToResolve: ["test.eth", "test.eth"], + }); + } + }); + + sendSafeInfo(); + + expect(renderedContainer).toBeTruthy(); + + const resolvedNameElement = await screen.findAllByTestId("resolvedName"); + expect(resolvedNameElement.map((value) => value.innerHTML)).toEqual([ + testData.addresses.receiver1, + testData.addresses.receiver1, + ]); + + const lookedUpAddressElement = await screen.findAllByTestId("lookedUpAddress"); + expect(lookedUpAddressElement.map((value) => value.innerHTML)).toEqual(["test.eth", "test.eth"]); + + expect(lookupAddress).toHaveBeenCalledTimes(1); + expect(resolveName).toHaveBeenCalledTimes(1); +}); + +/** + * we render the test component twice with the same props and check, that the web3Provider functions get called only once. + */ +test("null lookups / resolved addresses are cached", async () => { + const resolveName = jest.fn(async (name) => { + return Promise.resolve(null); + }); + const lookupAddress = jest.fn(async (address) => { + return Promise.resolve(null); + }); + const fakeWeb3Provider: Partial = { + getNetwork: () => + Promise.resolve({ chainId: 4, network: "rinkeby", _defaultProvider: () => null, name: "rinkeby" }), + resolveName: (name) => resolveName(name), + lookupAddress: (address) => lookupAddress(address), + }; + + jest.spyOn(ethers.providers, "Web3Provider").mockImplementation(() => fakeWeb3Provider as any); + let renderedContainer; + act(() => { + if (container !== null) { + renderedContainer = renderTestComponent(container, { + addressesToLookup: [testData.addresses.receiver3, testData.addresses.receiver3], + ensNamesToResolve: ["unknown.eth", "unknown.eth"], + }); + } + }); + + sendSafeInfo(); + + expect(renderedContainer).toBeTruthy(); + + const resolvedNameElement = await screen.findAllByTestId("resolvedName"); + expect(resolvedNameElement.map((value) => value.innerHTML)).toEqual(["", ""]); + + const lookedUpAddressElement = await screen.findAllByTestId("lookedUpAddress"); + expect(lookedUpAddressElement.map((value) => value.innerHTML)).toEqual(["", ""]); + + expect(lookupAddress).toHaveBeenCalledTimes(1); + expect(resolveName).toHaveBeenCalledTimes(1); +}); diff --git a/src/hooks/ens.ts b/src/hooks/ens.ts index 407d8ed8..750d00b0 100644 --- a/src/hooks/ens.ts +++ b/src/hooks/ens.ts @@ -31,7 +31,6 @@ export interface EnsResolver { export const useEnsResolver: () => EnsResolver = () => { const { safe, sdk } = useSafeAppsSDK(); const web3Provider = useMemo(() => new ethers.providers.Web3Provider(new SafeAppProvider(safe, sdk)), [sdk, safe]); - const resolveCache = useMemo(() => new Map(), []); const lookupCache = useMemo(() => new Map(), []); @@ -39,7 +38,8 @@ export const useEnsResolver: () => EnsResolver = () => { const cachedResolveName = useCallback( async (ensName: string) => { const cachedAddress = resolveCache.get(ensName); - const resolvedAddress = cachedAddress ? cachedAddress : await web3Provider.resolveName(ensName); + const resolvedAddress = + typeof cachedAddress !== "undefined" ? cachedAddress : await web3Provider.resolveName(ensName); if (!resolveCache.has(ensName)) { resolveCache.set(ensName, resolvedAddress); } diff --git a/src/setupTests.ts b/src/setupTests.ts index 5fdf0016..1c701873 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -3,3 +3,30 @@ // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom import "@testing-library/jest-dom/extend-expect"; +import "@testing-library/react"; + +// we have to mock some crypto functionality of the browser window for the gnosis safe mocks +import crypto from "crypto"; + +function getRandomValues(buf: Uint8Array) { + if (!(buf instanceof Uint8Array)) { + throw new TypeError("expected Uint8Array"); + } + if (buf.length > 65536) { + const e = new Error(); + e.message = + "Failed to execute 'getRandomValues' on 'Crypto': The " + + "ArrayBufferView's byte length (" + + buf.length + + ") exceeds the " + + "number of bytes of entropy available via this API (65536)."; + e.name = "QuotaExceededError"; + throw e; + } + const bytes = crypto.randomBytes(buf.length); + buf.set(bytes); +} + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +window.crypto = { getRandomValues }; diff --git a/src/test/safeUtil.ts b/src/test/safeUtil.ts new file mode 100644 index 00000000..fc8d1185 --- /dev/null +++ b/src/test/safeUtil.ts @@ -0,0 +1,55 @@ +import { SafeInfo } from "@gnosis.pm/safe-apps-sdk"; +import React from "react"; +import { act } from "react-dom/test-utils"; + +import { testData } from "./util"; + +let lastRegisteredEventHandler; +let lastTrackedParentRequestID: string | undefined; + +/** + * Registers mocks to specific window events and the useEffect hook in order to capture the postMessage call to the parent window (gnosis safe). + * + * After rendering the SafeProvider the test has to invoke sendSafeInfo to mock send a gnosis safe to the SafeProvider using the captured values. + * + * @see sendSafeInfo + */ +export const setupMocksForSafeProvider = () => { + let postMessageSpy: jest.SpyInstance; + let useEffectSpy: jest.SpyInstance; + + useEffectSpy = jest.spyOn(React, "useEffect"); + useEffectSpy.mockImplementation((f) => f()); + postMessageSpy = jest.spyOn(window.parent, "postMessage"); + + postMessageSpy.mockImplementation((message: any, options?: PostMessageOptions): Promise => { + if (message.method === "getSafeInfo") { + lastTrackedParentRequestID = message.id; + return Promise.resolve(testData.dummySafeInfo); + } + return Promise.reject("Implementation not mocked"); + }); + + jest.spyOn(window, "addEventListener").mockImplementationOnce((event, handler) => { + if (event === "message") { + lastRegisteredEventHandler = handler; + } + }); +}; + +/** + * Mocks a MessageEvent by calling the previously registered handler. + * This MessageEvent includes the SafeInfo and some required meta information. + * + * @param safeInfo Optional Safe Info data which should be sent to the SafeProvider. By default its the dummySafeInfo from testData. + */ +export const sendSafeInfo = (safeInfo: SafeInfo = testData.dummySafeInfo) => { + act(() => { + // we now send the a fake SafeInfo Object to the Safe Provider + lastRegisteredEventHandler({ + data: { ...safeInfo, version: "1.0", id: lastTrackedParentRequestID, success: true }, + source: window.parent, + origin: "*", + }); + }); +}; diff --git a/src/test/util.ts b/src/test/util.ts index aa0e7cef..8bc2b072 100644 --- a/src/test/util.ts +++ b/src/test/util.ts @@ -3,7 +3,7 @@ import { SafeInfo } from "@gnosis.pm/safe-apps-sdk"; import { TokenInfo } from "../utils"; const dummySafeInfo: SafeInfo = { - safeAddress: "0x123", + safeAddress: "0x1230000000000000000000000000000000000000", chainId: 4, threshold: 1, owners: [], diff --git a/yarn.lock b/yarn.lock index 60057181..c1aea54f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1164,6 +1164,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.12.5": + version "7.16.3" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.3.tgz#b86f0db02a04187a3c17caa77de69840165d42d5" + integrity sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.10.4", "@babel/template@^7.15.4", "@babel/template@^7.3.3": version "7.15.4" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.15.4.tgz#51898d35dcf3faa670c4ee6afcfd517ee139f194" @@ -2077,6 +2084,17 @@ "@types/yargs" "^16.0.0" chalk "^4.0.0" +"@jest/types@^27.2.5": + version "27.2.5" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-27.2.5.tgz#420765c052605e75686982d24b061b4cbba22132" + integrity sha512-nmuM4VuDtCZcY+eTpw+0nvstwReMsjPoj7ZR80/BbixulhLaiX+fbv8oeLW8WZlJMcsGQsTmMKT/iTZu1Uy/lQ== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^16.0.0" + chalk "^4.0.0" + "@manypkg/find-root@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@manypkg/find-root/-/find-root-1.1.0.tgz#a62d8ed1cd7e7d4c11d9d52a8397460b5d4ad29f" @@ -2394,6 +2412,20 @@ content-type "^1.0.4" tslib "^2.3.1" +"@testing-library/dom@^8.0.0": + version "8.11.1" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.11.1.tgz#03fa2684aa09ade589b460db46b4c7be9fc69753" + integrity sha512-3KQDyx9r0RKYailW2MiYrSSKEfH0GTkI51UGEvJenvcoDoeRYs0PZpi2SXqtnMClQvCqdtTTpOfFETDTVADpAg== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^4.2.0" + aria-query "^5.0.0" + chalk "^4.1.0" + dom-accessibility-api "^0.5.9" + lz-string "^1.4.4" + pretty-format "^27.0.2" + "@testing-library/jest-dom@^5.15.0": version "5.15.0" resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.15.0.tgz#4f5295dbc476a14aec3b07176434b3d51aae5da7" @@ -2409,6 +2441,14 @@ lodash "^4.17.15" redent "^3.0.0" +"@testing-library/react@^12.1.2": + version "12.1.2" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.2.tgz#f1bc9a45943461fa2a598bb4597df1ae044cfc76" + integrity sha512-ihQiEOklNyHIpo2Y8FREkyD1QAea054U0MVbwH1m8N9TxeFz+KoJ9LkqoKqJlzx2JDm56DVwaJ1r36JYxZM05g== + dependencies: + "@babel/runtime" "^7.12.5" + "@testing-library/dom" "^8.0.0" + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -2422,6 +2462,11 @@ lodash "^4.17.15" ts-essentials "^7.0.1" +"@types/aria-query@^4.2.0": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.2.tgz#ed4e0ad92306a704f9fb132a0cfcf77486dbe2bc" + integrity sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig== + "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7": version "7.1.16" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.16.tgz#bc12c74b7d65e82d29876b5d0baf5c625ac58702" @@ -3161,7 +3206,7 @@ ansi-regex@^4.1.0: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== -ansi-regex@^5.0.0: +ansi-regex@^5.0.0, ansi-regex@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== @@ -3221,6 +3266,11 @@ aria-query@^4.2.2: "@babel/runtime" "^7.10.2" "@babel/runtime-corejs3" "^7.10.2" +aria-query@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.0.0.tgz#210c21aaf469613ee8c9a62c7f86525e058db52c" + integrity sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg== + arity-n@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/arity-n/-/arity-n-1.0.4.tgz#d9e76b11733e08569c0847ae7b39b2860b30b745" @@ -4148,7 +4198,7 @@ chalk@^3.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" -chalk@^4.0.0: +chalk@^4.0.0, chalk@^4.1.0: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== @@ -5244,6 +5294,11 @@ dom-accessibility-api@^0.5.6: resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.7.tgz#8c2aa6325968f2933160a0b7dbb380893ddf3e7d" integrity sha512-ml3lJIq9YjUfM9TUnEPvEYWFSwivwIGBPKpewX7tii7fwCazA8yCioGdqQcNsItPpfFvSJ3VIdMQPj60LJhcQA== +dom-accessibility-api@^0.5.9: + version "0.5.10" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.10.tgz#caa6d08f60388d0bb4539dd75fe458a9a1d0014c" + integrity sha512-Xu9mD0UjrJisTmv7lmVSDMagQcU9R5hwAbxsaAE/35XPnPLJobbuREfV/rraiSaEj/UOvgrzQs66zyTWTlyd+g== + dom-converter@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.2.0.tgz#6721a9daee2e293682955b6afe416771627bb768" @@ -8489,6 +8544,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lz-string@^1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" + integrity sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY= + magic-string@^0.25.0, magic-string@^0.25.7: version "0.25.7" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051" @@ -10359,6 +10419,16 @@ pretty-format@^27.0.0, pretty-format@^27.2.0: ansi-styles "^5.0.0" react-is "^17.0.1" +pretty-format@^27.0.2: + version "27.2.5" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.2.5.tgz#7cfe2a8e8f01a5b5b29296a0b70f4140df0830c5" + integrity sha512-+nYn2z9GgicO9JiqmY25Xtq8SYfZ/5VCpEU3pppHHNAhd1y+ZXxmNPd1evmNcAd6Hz4iBV2kf0UpGth5A/VJ7g== + dependencies: + "@jest/types" "^27.2.5" + ansi-regex "^5.0.1" + ansi-styles "^5.0.0" + react-is "^17.0.1" + pretty-quick@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/pretty-quick/-/pretty-quick-3.1.1.tgz#93ca4e2dd38cc4e970e3f54a0ead317a25454688"