// A site allowed without "Remember my choice for this site" stays connected // when the background worker is stopped and started again // (https://git.eeqj.de/sneak/AutistMask/issues/526). // // Chrome stops the worker after about 30 seconds idle and evaluates the // module afresh on the next event, so nothing held at module scope survives // it. Such a connection used to be held exactly there, and so lasted until the // first idle spell. Each test here makes the connection in one load of the // background, loads the module afresh over the same storage, as the browser // does, and asks the new one. The paths that end a connection are driven after // that restart, so each is shown to end what the earlier worker stored. const { networkById } = require("../src/shared/networks"); const { makeStorageStub } = require("./support/storageStub"); const { removeAddressFromState, removeWalletFromState, } = require("../src/shared/walletDelete"); const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; const OTHER_ADDRESS = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; const SITE = "https://dapp.example"; const OTHER_SITE = "https://other.example"; const EXT_URL = "chrome-extension://autistmask/"; const FROM_POPUP = { url: EXT_URL + "src/popup/index.html" }; // One wallet with two addresses, so that one of them can be removed, and no // remembered sites, so that every connection here is one made without // "Remember". function storedProfile() { const mainnet = networkById("mainnet"); return { hasWallet: true, wallets: [ { name: "Wallet 1", type: "hd", addresses: [ { address: ADDRESS, balance: "0", tokenBalances: [] }, { address: OTHER_ADDRESS, balance: "0", tokenBalances: [] }, ], }, ], activeAddress: ADDRESS, networkId: "mainnet", rpcUrl: mainnet.defaultRpcUrl, blockscoutUrl: mainnet.defaultBlockscoutUrl, allowedSites: {}, deniedSites: {}, trackedTokens: [], }; } async function settle() { for (let i = 0; i < 50; i++) await Promise.resolve(); } afterEach(() => { delete global.chrome; jest.resetModules(); }); // Load the background module afresh, as the browser does when it starts the // worker, over `storage`. The storage stub stands in for the browser's own // storage, local and session, so it outlives each load. function startWorker(storage) { jest.resetModules(); jest.doMock("../src/shared/balances", () => ({ getProvider: () => ({}), refreshBalances: jest.fn(async () => {}), })); jest.doMock("../src/shared/phishingDomains", () => ({ isPhishingDomain: () => false, })); jest.doMock("../src/shared/alarms", () => ({ BALANCE_REFRESH_ALARM: "balance", BALANCE_REFRESH_PERIOD_MINUTES: 1, ensureRecurringAlarms: jest.fn(async () => {}), registerAlarmHandlers: jest.fn(), })); let messageListener = null; let connectListener = null; // Every URL the toolbar popup was set to open: a site's connection prompt // is shown there, so this is where its approval id appears. const toolbarPopups = []; const windowsCreated = []; const sentToTabs = []; global.chrome = { storage, runtime: { getURL: (path) => EXT_URL + path, onMessage: { addListener: (fn) => { messageListener = fn; }, }, onConnect: { addListener: (fn) => { connectListener = fn; }, }, getPlatformInfo: (cb) => cb({ os: "linux" }), // What the popup sends reaches the worker from the extension's own // page. sendMessage: (msg, callback) => { messageListener(msg, FROM_POPUP, callback || (() => {})); }, lastError: null, }, windows: { getLastFocused: (cb) => cb(null), create: (options, cb) => { windowsCreated.push(options); cb({ id: windowsCreated.length }); }, remove: (id, cb) => { if (cb) cb(); }, onRemoved: { addListener: () => {} }, }, tabs: { query: (q, cb) => cb([{ id: 1, url: SITE + "/app" }]), sendMessage: (tabId, msg, cb) => { sentToTabs.push(msg); if (cb) cb(); }, }, action: { setPopup: (o) => { toolbarPopups.push(o.popup); }, openPopup: () => Promise.resolve(), }, }; require("../src/background/index"); // A request from a site, answered once the worker has had time to answer. async function rpc(method, origin, params) { let response = null; messageListener( { type: "AUTISTMASK_RPC", method, params: params || [] }, { origin }, (r) => { response = r; }, ); await settle(); return response; } // A message from the popup, and the worker's answer to it. async function fromPopup(msg) { let response; messageListener(msg, FROM_POPUP, (r) => { response = r; }); await settle(); return response; } // The site asks to connect, and the user allows it in the prompt with // "Remember my choice for this site" left unchecked. async function connectWithoutRemember(origin) { let response = null; messageListener( { type: "AUTISTMASK_RPC", method: "eth_requestAccounts" }, { origin }, (r) => { response = r; }, ); await settle(); const id = new URL(toolbarPopups.at(-1), EXT_URL).searchParams.get( "approval", ); const decisionListeners = []; const port = { name: "approval:" + id, sender: { url: EXT_URL + "src/popup/index.html?approval=" + id }, onMessage: { addListener: (fn) => decisionListeners.push(fn) }, onDisconnect: { addListener: () => {} }, }; connectListener(port); for (const fn of decisionListeners) { fn( { type: "AUTISTMASK_APPROVAL_DECISION", approved: true, remember: false, }, port, ); } await settle(); return response; } return { rpc, fromPopup, connectWithoutRemember, windowsCreated, sentToTabs, }; } // A site connected without "Remember" in one worker, and the worker started // again over the same storage, where the site is still connected. async function connectedThenRestarted() { const storage = makeStorageStub({ autistmask: storedProfile() }); const first = startWorker(storage); expect(await first.connectWithoutRemember(SITE)).toEqual({ result: [ADDRESS], }); const worker = startWorker(storage); expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [ADDRESS], }); return { storage, worker }; } describe("a site allowed without Remember, after the worker restarts", () => { test("is still told the address, and is not asked again", async () => { const { worker } = await connectedThenRestarted(); expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [ADDRESS], }); expect(await worker.rpc("eth_requestAccounts", SITE)).toEqual({ result: [ADDRESS], }); }); test("is still listed under Connected Sites in Settings", async () => { const { worker } = await connectedThenRestarted(); expect( await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }), ).toEqual([SITE]); }); test("may still ask for a signature", async () => { // Refused with 4100 before the fix. Allowed, it raises the signing // prompt and waits on the user. const { worker } = await connectedThenRestarted(); const response = await worker.rpc("personal_sign", SITE, [ "0x48656c6c6f", ADDRESS, ]); expect(response).toBeNull(); expect(worker.windowsCreated).toHaveLength(1); }); test("is not connected after the browser restarts", async () => { // The browser empties session storage when it closes; the wallet's // own storage keeps nothing of the connection. const { storage } = await connectedThenRestarted(); const afterBrowserRestart = startWorker( makeStorageStub({ autistmask: storage.read("autistmask") }), ); expect(await afterBrowserRestart.rpc("eth_accounts", SITE)).toEqual({ result: [], }); }); }); describe("each way a connection allowed without Remember ends, after the worker restarts", () => { test("removing the site in Settings", async () => { const { worker } = await connectedThenRestarted(); expect(await worker.connectWithoutRemember(OTHER_SITE)).toEqual({ result: [ADDRESS], }); await worker.fromPopup({ type: "AUTISTMASK_REMOVE_SITE", origin: SITE, }); expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] }); expect(worker.sentToTabs).toEqual([ { type: "AUTISTMASK_EVENT", eventName: "accountsChanged", data: [], }, ]); expect( await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }), ).toEqual([OTHER_SITE]); expect(await worker.rpc("eth_accounts", OTHER_SITE)).toEqual({ result: [ADDRESS], }); }); test("switching to another address", async () => { // Switched away and back again, so the answer turns on the connection // having ended rather than on which address is active. const { storage, worker } = await connectedThenRestarted(); const switched = { ...storage.read("autistmask") }; switched.activeAddress = OTHER_ADDRESS; storage.write("autistmask", switched); await worker.fromPopup({ type: "AUTISTMASK_ACTIVE_CHANGED" }); switched.activeAddress = ADDRESS; storage.write("autistmask", switched); expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] }); expect( await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }), ).toEqual([]); }); // The two below ask while storage still names the removed address as // active, because the popup has not saved yet, so the answer turns on the // connection alone. test("removing the connected address", async () => { const { storage, worker } = await connectedThenRestarted(); const popupState = storage.read("autistmask"); expect(removeAddressFromState(popupState, 0, 0).removed).toBe(true); await settle(); expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] }); }); test("removing the wallet holding the connected address", async () => { const { storage, worker } = await connectedThenRestarted(); removeWalletFromState(storage.read("autistmask"), 0); await settle(); expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] }); }); test("removing a different address leaves it connected", async () => { const { storage, worker } = await connectedThenRestarted(); const popupState = storage.read("autistmask"); expect(removeAddressFromState(popupState, 0, 1).removed).toBe(true); await settle(); expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [ADDRESS], }); }); });