Files
AutistMask/tests/chainSwitchGate.test.js
T
sneak 482cd42ebb
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
fix: an open popup follows a network switch the background saves (closes #650)
When the background saves a network switch after the user approves a
site's request, or another popup page saves one, an open popup takes the
new network from the stored record inside a save, holding the lock, and
checks the record once at start-up for a switch saved before it
listened. It then leaves the page as a switch in Settings does: the cut
and the redraw are two Settings functions both paths call, and both
reset the popup's caches through one function. A switch in Settings is
now made inside its own save too, so one picked while a follow waits for
the lock is made after it. The popup never switches the network itself.

Model: opus-5-5
2026-10-21 00:26:46 +00:00

512 lines
18 KiB
JavaScript

// Who may move the active chain, and when.
//
// wallet_switchEthereumChain used to be answered for any origin at all, with
// no connection check and no prompt, so a page the user had never connected
// to could clear the [TESTNET] banner under someone who believed they were
// on Sepolia (https://git.eeqj.de/sneak/AutistMask/issues/308). Gated on the
// connection, a connected site could still move the wallet between mainnet and
// Sepolia without asking. Only the user switches the network: a connected
// site's request opens a prompt, and nothing changes unless the user approves
// it there (https://git.eeqj.de/sneak/AutistMask/issues/408).
//
// Every refusal is asserted as a refusal to ACT — the stored record unmoved
// and no chainChanged broadcast — because an error code alone would not
// distinguish a refusal from a switch that happened and then reported a
// failure.
//
// The endpoint half of #308 lives in tests/networkEndpoints.test.js, which
// covers the popup's chain switch; this file covers the background's, which
// goes through storage rather than the shared state singleton. The last block
// covers what the background tells open tabs when the user switches the
// network in Settings.
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// The site the persisted state has connected, and one it has never heard of.
const CONNECTED_ORIGIN = "https://dapp.example";
const STRANGER_ORIGIN = "https://stranger.example";
const EXT_URL = "chrome-extension://autistmask/";
const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia");
// The user's own node, so a switch that happens is visible as the loss of it.
const CUSTOM_RPC = "http://127.0.0.1:8545";
// A balance a switch would clear, so a switch that happens is visible here too.
function walletFixture() {
return [
{
name: "Wallet 1",
type: "hd",
addresses: [
{ address: ADDRESS, balance: "1.5", tokenBalances: [] },
],
},
];
}
// Let the handler's promise chain run to the next suspension point. The gate
// reads storage before it answers, so the response is several awaits deep.
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
});
// Load the background worker against stubbed browser APIs, with the real
// chain-switch and persistence modules behind it, and return the handles to
// drive it.
function loadBackground() {
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(),
}));
// Storage is the only wallet state there is. The background reads and
// writes it per call — it holds no in-memory copy and cannot reach the
// shared singleton — so a switch that happened is visible here as a
// written record, and one that did not is visible as its absence.
const persisted = {
networkId: "mainnet",
rpcUrl: CUSTOM_RPC,
blockscoutUrl: MAINNET.defaultBlockscoutUrl,
networkEndpoints: {},
wallets: walletFixture(),
lastBalanceRefresh: 1,
tokenHolderCache: {},
fraudContracts: [],
activeAddress: ADDRESS,
allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] },
deniedSites: {},
};
const storage = makeStorageStub({ autistmask: persisted });
let messageListener = null;
let connectListener = null;
let windowRemovedListener = null;
// The URL of every approval window the background opened. The approval id
// is in it, and that is how the popup learns which approval it answers.
const opened = [];
// Every message the background pushed at a content script. chainChanged
// is what tells a page the wallet moved, so a switch is visible here as
// well as in the state.
const toTabs = [];
global.chrome = {
storage,
runtime: {
getURL: (path) => EXT_URL + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: {
addListener: (fn) => {
connectListener = fn;
},
},
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (options, cb) => {
opened.push(options.url);
cb({ id: opened.length });
},
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: {
addListener: (fn) => {
windowRemovedListener = fn;
},
},
},
tabs: {
query: (queryInfo, cb) => cb([{ id: 1 }]),
sendMessage: (tabId, message, cb) => {
toTabs.push(message);
if (cb) cb();
},
},
action: { setPopup: () => {} },
};
require("../src/background/index");
// A page's request. Its answer is read with result(), which is null for as
// long as the request is waiting on the user.
async function switchChain(chainId, origin) {
let result = null;
messageListener(
{
type: "AUTISTMASK_RPC",
method: "wallet_switchEthereumChain",
params: [{ chainId }],
},
{ origin },
(r) => {
result = r;
},
);
await settle();
return { result: () => result };
}
function promptId() {
return new URL(opened[opened.length - 1]).searchParams.get("approval");
}
// What the popup is told to show for the prompt.
function describePrompt() {
let reply = null;
messageListener(
{ type: "AUTISTMASK_GET_APPROVAL", id: promptId() },
{ url: EXT_URL + "src/popup/index.html" },
(r) => {
reply = r;
},
);
return reply;
}
// The user's answer, as the popup sends it: on the port named for the
// approval, from the extension's own page, and then the window closes.
async function answerPrompt(approved) {
const onMessage = [];
const onDisconnect = [];
const port = {
name: "approval:" + promptId(),
sender: { url: EXT_URL + "src/popup/index.html" },
onMessage: { addListener: (fn) => onMessage.push(fn) },
onDisconnect: { addListener: (fn) => onDisconnect.push(fn) },
};
connectListener(port);
for (const fn of onMessage) {
fn(
{
type: "AUTISTMASK_APPROVAL_DECISION",
approved,
remember: false,
},
port,
);
}
for (const fn of onDisconnect) fn(port);
await settle();
}
// The user closes the prompt window without answering it.
async function closePrompt() {
windowRemovedListener(opened.length);
await settle();
}
return {
switchChain,
describePrompt,
answerPrompt,
closePrompt,
opened,
// A message to the background from `sender`, and its answer.
send: (msg, sender) => {
let reply = null;
messageListener(msg, sender, (r) => {
reply = r;
});
return reply;
},
walletState: () => storage.read("autistmask"),
chainChangedEvents: () =>
toTabs.filter((m) => m.eventName === "chainChanged"),
};
}
const USER_REJECTED = { code: 4001, message: "User rejected the request." };
describe("wallet_switchEthereumChain is gated on the connection", () => {
test("an origin the wallet was never connected to is refused with 4100", async () => {
const bg = loadBackground();
const before = bg.walletState();
const request = await bg.switchChain(SEPOLIA.chainId, STRANGER_ORIGIN);
expect(request.result().error).toEqual({
code: 4100,
message: "Unauthorized",
});
expect(request.result().result).toBeUndefined();
// The refusal has to be a refusal to ACT, not just an error string:
// the wallet is still on mainnet, still on the user's own node, and
// no page was told the chain moved. Nor was the user asked.
expect(bg.walletState()).toEqual(before);
expect(bg.chainChangedEvents()).toEqual([]);
expect(bg.opened).toEqual([]);
});
test("an unconnected origin is refused even for the chain already active", async () => {
const bg = loadBackground();
const request = await bg.switchChain(MAINNET.chainId, STRANGER_ORIGIN);
expect(request.result().error).toEqual({
code: 4100,
message: "Unauthorized",
});
});
test("an unconnected origin is refused before the unsupported-chain answer", async () => {
const bg = loadBackground();
const request = await bg.switchChain("0x89", STRANGER_ORIGIN);
expect(request.result().error.code).toBe(4100);
});
});
describe("only the user switches the network", () => {
test("a connected site's request changes nothing while the prompt is open", async () => {
const bg = loadBackground();
const before = bg.walletState();
const request = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
// Waiting on the user, with one prompt on screen naming the site and
// both networks.
expect(request.result()).toBeNull();
expect(bg.opened).toHaveLength(1);
expect(bg.describePrompt()).toMatchObject({
origin: CONNECTED_ORIGIN,
type: "network",
currentNetworkId: "mainnet",
requestedNetworkId: "sepolia",
});
// The network, the endpoints and the balances are as they were, and
// no page was told otherwise.
expect(bg.walletState()).toEqual(before);
expect(bg.chainChangedEvents()).toEqual([]);
});
test("approving the prompt switches the network", async () => {
const bg = loadBackground();
const request = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
await bg.answerPrompt(true);
expect(request.result()).toEqual({ result: null });
const after = bg.walletState();
expect(after.networkId).toBe("sepolia");
expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
expect(after.wallets[0].addresses[0].balance).toBe("0");
expect(bg.chainChangedEvents()).toEqual([
{
type: "AUTISTMASK_EVENT",
eventName: "chainChanged",
data: SEPOLIA.chainId,
},
]);
});
test("rejecting the prompt changes nothing and answers 4001", async () => {
const bg = loadBackground();
const before = bg.walletState();
const request = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
await bg.answerPrompt(false);
expect(request.result()).toEqual({ error: USER_REJECTED });
expect(bg.walletState()).toEqual(before);
expect(bg.chainChangedEvents()).toEqual([]);
});
test("closing the prompt without answering changes nothing and answers 4001", async () => {
const bg = loadBackground();
const before = bg.walletState();
const request = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
await bg.closePrompt();
expect(request.result()).toEqual({ error: USER_REJECTED });
expect(bg.walletState()).toEqual(before);
expect(bg.chainChangedEvents()).toEqual([]);
});
test("a request for the chain already active opens no prompt", async () => {
const bg = loadBackground();
const before = bg.walletState();
const request = await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN);
expect(request.result()).toEqual({ result: null });
expect(bg.opened).toEqual([]);
expect(bg.walletState()).toEqual(before);
expect(bg.chainChangedEvents()).toEqual([]);
});
test("a second request while the prompt is open is refused with -32002", async () => {
const bg = loadBackground();
const first = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
const second = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
expect(second.result().error.code).toBe(-32002);
expect(bg.opened).toHaveLength(1);
// The first prompt still decides.
await bg.answerPrompt(true);
expect(first.result()).toEqual({ result: null });
expect(bg.walletState().networkId).toBe("sepolia");
});
test("a request for an unsupported chain still gets 4902 and no prompt", async () => {
const bg = loadBackground();
const request = await bg.switchChain("0x89", CONNECTED_ORIGIN);
expect(request.result().error.code).toBe(4902);
expect(bg.opened).toEqual([]);
expect(bg.walletState().networkId).toBe("mainnet");
});
test("an approved switch keeps the user's endpoint", async () => {
const bg = loadBackground();
await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
await bg.answerPrompt(true);
expect(bg.walletState().rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN);
await bg.answerPrompt(true);
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
});
});
// Switching the network in Settings tells open pages, as an approved site
// request does (https://git.eeqj.de/sneak/AutistMask/issues/500). These drive
// the real Settings view over the background's storage, with what it sends
// delivered to the background from the extension's own page.
describe("switching the network in Settings tells every open tab", () => {
const POPUP = { url: EXT_URL + "src/popup/index.html" };
// A stand-in for one DOM node: enough of an element for init() to set
// properties on it and hang listeners off it.
function fakeElement() {
return {
value: "",
checked: false,
textContent: "",
style: {},
dataset: {},
classList: { add() {}, remove() {} },
listeners: {},
addEventListener(event, handler) {
this.listeners[event] = handler;
},
querySelectorAll: () => [],
};
}
// Settings, opened the way the popup opens it. Returns its network
// selector.
async function openSettings(bg) {
const elements = {};
const element = (id) => (elements[id] ||= fakeElement());
jest.doMock("../src/popup/views/helpers", () => ({
$: element,
showView: () => {},
updateDebugBanner: () => {},
showFlash: () => {},
escapeHtml: (s) => s,
flashCopyFeedback: () => {},
goBack: () => {},
pushCurrentView: () => {},
onViewLeave: () => {},
VIEWS: [],
}));
global.chrome.runtime.sendMessage = (msg) => {
bg.send(msg, POPUP);
};
const { state, loadState } = require("../src/shared/state");
await loadState();
// Set by showView(), which is mocked above.
state.currentView = "settings";
require("../src/popup/views/settings").init({
pageClosed: new AbortController().signal,
});
const select = element("settings-network");
select.value = "mainnet";
return select;
}
// The user picks `networkId` in the selector.
async function choose(select, networkId) {
select.value = networkId;
await select.listeners.change();
await settle();
}
afterEach(() => {
jest.dontMock("../src/popup/views/helpers");
});
test("switching to the other network sends chainChanged once, with its chain id", async () => {
const bg = loadBackground();
const select = await openSettings(bg);
await choose(select, "sepolia");
expect(bg.walletState().networkId).toBe("sepolia");
expect(bg.chainChangedEvents()).toEqual([
{
type: "AUTISTMASK_EVENT",
eventName: "chainChanged",
data: SEPOLIA.chainId,
},
]);
});
test("choosing the network already active changes nothing and sends nothing", async () => {
const bg = loadBackground();
const select = await openSettings(bg);
const before = bg.walletState();
await choose(select, "mainnet");
expect(bg.walletState()).toEqual(before);
expect(bg.chainChangedEvents()).toEqual([]);
});
test("a page cannot make the background send chainChanged", async () => {
const bg = loadBackground();
const reply = bg.send(
{ type: "AUTISTMASK_NETWORK_CHANGED", chainId: SEPOLIA.chainId },
{ url: CONNECTED_ORIGIN + "/" },
);
await settle();
expect(reply).toEqual({ error: "Unauthorized sender" });
expect(bg.chainChangedEvents()).toEqual([]);
});
});