Files
AutistMask/tests/backgroundStateIsolation.test.js
T
sneak d377b1f9fe
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
fix: open an approval window while another one has focus (closes #290)
The background centred each approval window on the last focused window,
which could be an earlier approval window still open. Headless Chrome
reports one as 1280x720, so the position came out where the browser refused
to create the window ("Bounds must be at least 50% within visible screen
space"), and the request failed with -32603 and no window. It now centres on
the last focused browser window.

Under load a test in the Chrome suite raised its prompt while the previous
test's window was still closing, and either hit that refusal or took the
closing window for its own. The runner now closes every approval window
between tests.

Model: opus-5-5
2026-10-05 03:37:47 +00:00

398 lines
14 KiB
JavaScript

// What one background handler's state can do to another's while both are in
// flight.
//
// The background used to read and write the module-level `state` singleton in
// src/shared/state.js — one object, shared by every handler in the worker,
// replaced wholesale by any loadState(). Two consequences, both covered here
// and both from https://git.eeqj.de/sneak/AutistMask/issues/324:
//
// - A transaction attempt captured the chain id at its loadState() and then
// read the ENDPOINT off the singleton several awaits later. A chain switch
// committed in that window moved the endpoint under an artifact already
// verified against the old chain, so it would have gone to the new chain's
// node — the very thing the verification exists to prevent.
//
// - backgroundRefresh() handed the singleton's wallets to refreshBalances(),
// which mutates address objects in place across a multi-second network
// round trip. Any concurrent handler that loaded state replaced those
// objects, so the refreshed balances landed on detached ones and the save
// that followed persisted the pre-refresh values — while still stamping
// lastBalanceRefresh, suppressing the redo.
//
// Both use the real persistence path over a cloning storage stub. Nothing here
// asserts the absence of a loadState() call; each asserts the OUTCOME, so it
// holds against any implementation that gets the outcome right.
const { Wallet } = require("ethers");
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const SIGNER_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const signer = new Wallet(SIGNER_KEY);
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example";
const EXT_URL = "chrome-extension://autistmask/";
const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia");
const NONCE = 7;
const REFRESHED_BALANCE = "1.5";
// The transaction the background populates, and the artifact signed from it.
// Its chain is a parameter because the whole subject here is a chain moving
// under work already committed to one.
function populated(chainId) {
return {
type: 2,
chainId,
nonce: NONCE,
gasLimit: 100000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
to: RECIPIENT,
value: 10000000000000000n,
data: "0x",
};
}
function storedProfile(networkId) {
const net = networkById(networkId);
return {
hasWallet: true,
wallets: [
{
name: "Wallet 1",
type: "hd",
xpub: "xpub-1",
addresses: [
{
address: signer.address,
balance: "0.0",
tokenBalances: [],
},
],
},
],
activeAddress: signer.address,
networkId,
rpcUrl: net.defaultRpcUrl,
blockscoutUrl: net.defaultBlockscoutUrl,
allowedSites: { [signer.address]: [CONNECTED_ORIGIN] },
deniedSites: {},
trackedTokens: [],
lastBalanceRefresh: 0,
};
}
async function settle() {
for (let i = 0; i < 60; i++) await Promise.resolve();
}
function deferred() {
let resolve;
const promise = new Promise((res) => {
resolve = res;
});
return { promise, resolve };
}
afterEach(() => {
delete global.chrome;
});
// The background worker over a cloning storage stub, with the network and the
// clock stubbed out. `opts.refreshBalances` replaces the balance refresh so a
// test can hold one open across another handler's whole turn.
function loadWorker(networkId, opts) {
const options = opts || {};
jest.resetModules();
// Every provider this worker constructs, in order, with the endpoint and
// the network id it was given. The subject of the first test is which pair
// reaches the broadcast.
const providers = [];
const broadcastTransaction = jest.fn(async () => ({ hash: "0xfeed" }));
jest.doMock("../src/shared/balances", () => ({
getProvider: (rpcUrl, networkId2) => {
const provider = {
rpcUrl,
networkId: networkId2,
broadcastTransaction,
getNetwork: async () => ({
chainId: BigInt(networkById(networkId2).networkVersion),
}),
getTransactionCount: async () => NONCE,
estimateGas: async () => 100000n,
getFeeData: async () => ({
gasPrice: 2000000000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
}),
};
providers.push(provider);
return provider;
},
refreshBalances:
options.refreshBalances || jest.fn(async () => undefined),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
}));
let alarmHandlers = {};
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn((handlers) => {
alarmHandlers = handlers;
}),
}));
const storage = makeStorageStub({ autistmask: storedProfile(networkId) });
// A hook the tests use to suspend one handler mid-flight, so the other one
// runs entirely inside its window.
let getHook = null;
const realGet = storage.local.get;
storage.local.get = jest.fn(async (key) => {
if (getHook) await getHook();
return realGet(key);
});
let messageListener = null;
// Every popup URL the background opened. The approval id is in it, and
// that is how the popup learns which approval it is answering.
const createdUrls = [];
global.chrome = {
storage,
runtime: {
getURL: (path) => EXT_URL + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
lastError: null,
},
windows: {
getLastFocused: (queryOptions, cb) => cb(null),
create: (createOpts, cb) => {
createdUrls.push(createOpts.url);
cb({ id: createdUrls.length });
},
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: { addListener: () => {} },
},
tabs: {
query: (queryInfo, cb) => cb([{ id: 1 }]),
sendMessage: (tabId, message, cb) => {
if (cb) cb();
},
},
action: { setPopup: () => {} },
};
require("../src/background/index");
function send(msg, sender) {
let result = null;
const kept = messageListener(msg, sender, (r) => {
result = r;
});
return { kept, result: () => result };
}
function rpc(method, params, origin) {
return send(
{ type: "AUTISTMASK_RPC", method, params },
{ origin: origin || CONNECTED_ORIGIN },
);
}
return {
rpc,
send,
providers,
broadcastTransaction,
persisted: () => storage.read("autistmask"),
setGetHook: (hook) => {
getHook = hook;
},
fromPopup: { url: EXT_URL + "src/popup/index.html" },
fireBalanceAlarm: () => alarmHandlers.balance(),
lastApprovalId: () => {
const url = createdUrls[createdUrls.length - 1];
if (!url) return null;
return new URL(url, EXT_URL).searchParams.get("approval");
},
};
}
describe("a chain switch under a transaction already committed to a chain", () => {
// Item 4 of https://git.eeqj.de/sneak/AutistMask/issues/324.
//
// The artifact is verified against the chain read at the top of the
// attempt. Whatever endpoint it is then broadcast to has to be that same
// chain's — otherwise the wallet checks a transaction against Sepolia and
// sends it to a mainnet node. A connected site can switch the chain at any
// moment, including this one.
test("the artifact is broadcast to the endpoint of the chain it was verified against", async () => {
const bg = loadWorker("sepolia");
// Raise the approval, then find its id from the popup's own fetch.
bg.rpc("eth_sendTransaction", [
{
from: signer.address,
to: RECIPIENT,
value: "0x2386f26fc10000",
data: "0x",
},
]);
await settle();
const id = bg.lastApprovalId();
expect(id).toBeTruthy();
// The popup signs what it was shown: Sepolia.
const rawSignedTx = await signer.signTransaction(
populated(Number(SEPOLIA.networkVersion)),
);
// A connected site switches the chain while the attempt is running,
// and the switch is committed to storage in full before the attempt
// goes any further.
//
// It is fired from inside the attempt's SECOND state read, because
// that is where the window used to be: the chain id was captured at
// the first read and the endpoint was taken off the singleton several
// awaits later, so a switch landing between them moved the endpoint
// under an artifact already verified against the old chain. An
// implementation that takes both from one read has no second read for
// this to fire on, and the switch below runs after the attempt is
// done instead — which is the point.
let reads = 0;
let switched = null;
const doSwitch = async () => {
switched = bg.rpc("wallet_switchEthereumChain", [
{ chainId: MAINNET.chainId },
]);
await settle();
};
bg.setGetHook(async () => {
reads++;
if (reads !== 2) return;
bg.setGetHook(null);
await doSwitch();
});
const attempt = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx,
},
{ url: bg.fromPopup.url },
);
await settle();
bg.setGetHook(null);
if (!switched) await doSwitch();
expect(switched.result()).toEqual({ result: null });
expect(bg.persisted().networkId).toBe("mainnet");
await settle();
// It went out, and it went out to Sepolia's node — the chain the
// artifact was verified against. Reading the endpoint separately from
// the chain id put mainnet's here.
expect(attempt.result()).toEqual({ txHash: "0xfeed" });
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
const used = bg.providers[bg.providers.length - 1];
expect(used.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
expect(used.networkId).toBe("sepolia");
});
});
describe("a balance refresh under another handler's state read", () => {
// Item 5 of https://git.eeqj.de/sneak/AutistMask/issues/324.
//
// The trigger is a same-chain wallet_switchEthereumChain from a connected
// site: it answers { result: null } and changes nothing, so the ONLY thing
// it can do to the refresh is what its state read does. On the singleton
// that read replaced state.wallets, detaching the objects the refresh was
// mutating.
test("a chain read arriving mid-refresh does not discard the refresh", async () => {
const roundTrip = deferred();
const reachedNetwork = deferred();
const bg = loadWorker("sepolia", {
refreshBalances: async (wallets) => {
reachedNetwork.resolve();
await roundTrip.promise;
// In place, on the objects handed in — as balances.js does.
wallets[0].addresses[0].balance = REFRESHED_BALANCE;
},
});
const refresh = bg.fireBalanceAlarm();
await reachedNetwork.promise;
const answered = bg.rpc("wallet_switchEthereumChain", [
{ chainId: SEPOLIA.chainId },
]);
await settle();
expect(answered.result()).toEqual({ result: null });
roundTrip.resolve();
await refresh;
expect(bg.persisted().wallets[0].addresses[0].balance).toBe(
REFRESHED_BALANCE,
);
expect(bg.persisted().lastBalanceRefresh).toBeGreaterThan(0);
});
// The other half of "does not publish a shared object": a wallet added
// while the refresh was in flight must survive the refresh's own write.
test("a wallet added mid-refresh survives the refresh's write", async () => {
const roundTrip = deferred();
const reachedNetwork = deferred();
const bg = loadWorker("sepolia", {
refreshBalances: async (wallets) => {
reachedNetwork.resolve();
await roundTrip.promise;
wallets[0].addresses[0].balance = REFRESHED_BALANCE;
},
});
const refresh = bg.fireBalanceAlarm();
await reachedNetwork.promise;
// Another extension page adds a wallet while the round trip is out.
const during = bg.persisted();
during.wallets.push({
name: "Wallet 2",
type: "hd",
xpub: "xpub-2",
addresses: [
{ address: RECIPIENT, balance: "0.0", tokenBalances: [] },
],
});
global.chrome.storage.write("autistmask", during);
roundTrip.resolve();
await refresh;
const after = bg.persisted();
expect(after.wallets).toHaveLength(2);
expect(after.wallets[0].addresses[0].balance).toBe(REFRESHED_BALANCE);
});
});