Some tests left timers running when they ended, and --forceExit hid them. - Six background test files leave an approval pending, so the background's 20-second timer ran forever. Each file now stubs that timer, as two blocks in tests/backgroundApproval.test.js already did. - Five popup test files show a flash message, and its timer outlived the test. They now run on fake timers. Both test scripts in package.json drop --forceExit. Model: opus-5-5
366 lines
12 KiB
JavaScript
366 lines
12 KiB
JavaScript
// 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();
|
|
}
|
|
|
|
// While an approval is pending, the background starts a 20-second timer to
|
|
// keep itself running. The timer is stubbed and never starts, so no test in
|
|
// this file leaves it running.
|
|
let keepRunningTimer;
|
|
beforeEach(() => {
|
|
keepRunningTimer = jest
|
|
.spyOn(global, "setInterval")
|
|
.mockImplementation(() => 1);
|
|
});
|
|
|
|
afterEach(() => {
|
|
keepRunningTimer.mockRestore();
|
|
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],
|
|
});
|
|
});
|
|
});
|