harden: stop the background reading the shared state singleton, and enforce it at build time (closes #324)
All checks were successful
check / check (push) Successful in 33s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 31s

Five defects, one of which destroyed every wallet, came from src/background reading and writing the module-level state singleton the MV3 worker never populates, which silently served DEFAULT_STATE. Each point fix created the next defect. The background now has its own per-call getState() and a queued read-modify-write updateState(); the singleton is unreachable from it, and an unpopulated read throws instead of serving defaults.

The prohibition is enforced by the build, not by review: build.js asserts over esbuild's own metafile that no forbidden module is an input of a background bundle, so every specifier syntax esbuild resolves is covered, and both halves of the table are checked for rot -- a stale key, a stale module, an empty list, or an unlisted entry point under src/background/ all fail the build. The ESLint rule remains as fast local feedback and reads the same shared table. Known bounds are documented where the table lives.

Also closes #320: getProvider() now requires a validated network id, so a cold worker no longer prepares a non-mainnet dApp transaction for mainnet and gets refused by the wallet's own verifier. backgroundRefresh() no longer mutates address objects across a network round trip, the broadcast path takes its endpoint and chain id from one snapshot, and eight test storage stubs now structured-clone on get as the real chrome.storage.local does.

closes #320
This commit was merged in pull request #344.
This commit is contained in:
2026-08-23 17:57:30 +02:00
parent 36bc6bee0e
commit bd0a626e7b
40 changed files with 2959 additions and 653 deletions

View File

@@ -8,6 +8,8 @@
// A controllable clock plus a stubbed balance refresh, so a cadence test can
// measure the interval between refreshes that actually happened rather than
// asserting the interval someone intended.
const { makeStorageStub } = require("./support/storageStub");
let mockNow = 0;
const mockBalanceRefreshAt = [];
@@ -247,32 +249,17 @@ describe("alarms module", () => {
// Loads the background worker against stubbed browser APIs. The returned
// store is the extension storage the worker sees, so a test can seed wallet
// state and read back what the worker persisted.
// The stub clones in both directions, as the real chrome.storage.local does,
// and carries the latency simulation above on every operation. It used to
// alias, which for this file meant the worker's in-memory wallets and the
// "stored" ones were one object — see tests/support/storageStub.js.
function loadBackground(initialStore = {}) {
const storageStore = initialStore;
const storage = makeStorageStub(initialStore, mockStorageTick);
const alarmsStub = makeAlarmsStub();
const listeners = { onInstalled: [], onStartup: [] };
global.chrome = {
alarms: alarmsStub,
storage: {
local: {
get: async (key) => {
mockStorageTick();
return Object.prototype.hasOwnProperty.call(
storageStore,
key,
)
? { [key]: storageStore[key] }
: {};
},
set: async (items) => {
mockStorageTick();
Object.assign(storageStore, items);
},
remove: async (key) => {
delete storageStore[key];
},
},
},
storage,
runtime: {
onMessage: { addListener: jest.fn() },
onConnect: { addListener: jest.fn() },
@@ -301,7 +288,7 @@ function loadBackground(initialStore = {}) {
}));
jest.resetModules();
require("../src/background/index");
return { alarmsStub, listeners, store: storageStore };
return { alarmsStub, listeners, storage };
}
// Flush the promise chains the startup path and the alarm handlers run on.
@@ -476,12 +463,16 @@ describe("balance refresh steady-state cadence", () => {
// The guard's actual job, and the reason it is shortened rather than
// removed: while the popup is open it refreshes every 10 seconds and
// stamps the same field, and the background job has nothing to add.
const store = seededStore();
const { alarmsStub } = loadBackground(store);
const { alarmsStub, storage } = loadBackground(seededStore());
await settle();
mockNow += PERIOD_MS;
store.autistmask.lastBalanceRefresh = mockNow - 10 * 1000;
// As the open popup's own refresh would leave it: written to storage,
// not poked into an object the worker happens to share.
storage.write("autistmask", {
...storage.read("autistmask"),
lastBalanceRefresh: mockNow - 10 * 1000,
});
alarmsStub.fire(BALANCE_REFRESH_ALARM);
await settle();

View File

@@ -24,6 +24,7 @@ const { Network, Wallet } = require("ethers");
// before any jest.doMock() of the module, so the copy assertions below check
// what the user is actually shown.
const { describeSigningFailure } = require("../src/shared/approvalVerify");
const { makeStorageStub } = require("./support/storageStub");
const SIGNER_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
@@ -137,22 +138,22 @@ function loadBackground(options) {
jest.resetModules();
const broadcastTransaction = jest.fn();
const loadState = jest.fn(opts.loadState || (async () => {}));
// The network the wallet is on, which the tests switch under a pending
// approval. The node the transaction is populated against is on the same
// one, as it would be: switching networks switches the RPC endpoint too.
let chain = MAINNET;
// The node the transaction is populated against is on whatever chain the
// stored profile says, as it would be: switching networks switches the RPC
// endpoint too. The background takes the network from storage per call —
// it holds no in-memory copy — so this reads the record rather than a
// variable the test keeps alongside it.
const chainOf = (networkId) =>
networkId === "sepolia" ? SEPOLIA : MAINNET;
jest.doMock("../src/shared/state", () => ({
state: { rpcUrl: "https://rpc.invalid", wallets: [] },
loadState,
saveState: jest.fn(async () => {}),
currentNetwork: () => ({ chainId: chain.hex }),
}));
jest.doMock("../src/shared/balances", () => ({
getProvider: () =>
fakeProvider(broadcastTransaction, opts.provider, chain.num),
getProvider: (rpcUrl, networkId) =>
fakeProvider(
broadcastTransaction,
opts.provider,
chainOf(networkId).num,
),
refreshBalances: jest.fn(async () => {}),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
@@ -177,12 +178,31 @@ function loadBackground(options) {
wallets: [
{ name: "Wallet 1", type: "hd", addresses: [signer.address] },
],
networkId: "mainnet",
rpcUrl: "https://rpc.invalid",
activeAddress: signer.address,
allowedSites: { [signer.address]: [HOSTNAME] },
deniedSites: {},
};
// The one wallet state there is. The background reads it per call and
// writes it read-modify-write; it holds no in-memory copy and cannot reach
// the shared singleton. Clones in both directions, as the real API does —
// the stub here used to hand back the live record and drop every write on
// the floor, so a test could neither see what was persisted nor be sure
// what it read had crossed the boundary
// (https://git.eeqj.de/sneak/AutistMask/issues/324).
const storage = makeStorageStub({ autistmask: persisted });
// A test that needs the state read itself to misbehave installs a hook —
// a stall, a throw — in place of the next reads. Armed after setup so
// that raising the approval is not what fails.
let storageGetHook = opts.storageGet || null;
const realGet = storage.local.get;
storage.local.get = jest.fn(async (key) =>
storageGetHook ? storageGetHook(key) : realGet(key),
);
let messageListener = null;
let windowRemovedListener = null;
let connectListener = null;
@@ -194,15 +214,7 @@ function loadBackground(options) {
const actionPopups = [];
global.chrome = {
storage: {
local: {
get: jest.fn(
opts.storageGet ||
(async () => ({ autistmask: persisted })),
),
set: jest.fn(async () => {}),
},
},
storage,
runtime: {
getURL: (path) => EXT_URL + path,
onMessage: {
@@ -401,18 +413,32 @@ function loadBackground(options) {
connectApproval,
closeWindow,
broadcastTransaction,
loadState,
created,
removed,
storage,
// The user switching account in the toolbar popup, as the background
// sees it: the persisted active address changes underneath a pending
// approval.
setActiveAddress: (address) => {
persisted.activeAddress = address;
storage.write("autistmask", {
...storage.read("autistmask"),
activeAddress: address,
});
},
// The user switching network in the toolbar popup.
// The user switching network in the toolbar popup. It moves the stored
// network and the endpoint together, as a real switch does.
setNetwork: (network) => {
chain = network;
const networkId = network === SEPOLIA ? "sepolia" : "mainnet";
storage.write("autistmask", {
...storage.read("autistmask"),
networkId,
rpcUrl: "https://rpc-" + networkId + ".invalid",
});
},
// Make the next state reads misbehave — stall, throw — without
// touching the reads that raised the approval. Pass null to restore.
setStateReadHook: (hook) => {
storageGetHook = hook;
},
fromPopup: { url: EXT_URL + "src/popup/index.html" },
};
@@ -677,15 +703,16 @@ describe("one transaction approval at a time", () => {
// page never — and holds the slot for the life of the worker with it.
test("an approval whose window closed under a failed attempt is answered, and frees the next request", async () => {
const stalled = deferred();
const bg = loadBackground({
loadState: async () => {
await stalled.promise;
throw new Error("The wallet data could not be read.");
},
});
const bg = loadBackground();
const first = bg.requestTx();
await settle();
// Armed only now: the approval was raised against a working state
// read, and it is the ATTEMPT's read that hangs and then fails.
bg.setStateReadHook(async () => {
await stalled.promise;
throw new Error("The wallet data could not be read.");
});
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
@@ -709,6 +736,7 @@ describe("one transaction approval at a time", () => {
error: { code: 4001, message: "User rejected the request." },
});
bg.setStateReadHook(null);
const second = bg.requestTx();
await settle();
expect(second.result()).toBeNull();
@@ -1226,19 +1254,18 @@ describe("what the approval is verified against", () => {
// The interlock must not cost the retry the approval exists to allow.
describe("the interlock releases a failed attempt", () => {
test("a retryable failure before the broadcast leaves the approval usable", async () => {
let failNext = true;
const bg = loadBackground({
loadState: async () => {
if (failNext) {
failNext = false;
throw new Error("storage unavailable");
}
},
});
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const id = pending.id();
// The attempt's state read fails once, then works: nothing was
// broadcast, so the approval must survive for the retry.
bg.setStateReadHook(() => {
bg.setStateReadHook(null);
throw new Error("storage unavailable");
});
const first = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",

View File

@@ -0,0 +1,398 @@
// 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 CONNECTED_HOSTNAME = "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_HOSTNAME] },
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: (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);
});
});

View File

@@ -0,0 +1,270 @@
// The lint rule that keeps src/shared/state.js out of the background bundle
// (script/lib/eslint/noStateSingletonInBackground.js).
//
// Five defects, one of which destroyed a wallet, came from background code
// reaching that singleton, and each point fix created the next site
// (https://git.eeqj.de/sneak/AutistMask/issues/324).
//
// What this file does NOT do is establish that the singleton cannot reach the
// background bundle. That is build.js's FORBIDDEN_INPUTS assertion, which reads
// esbuild's metafile and so cannot be evaded by a syntax a matcher does not
// know; it is pinned by tests/buildForbiddenInputs.test.js. The rule under test
// here is fast local feedback in front of that, and these cases pin the shapes
// it is known to catch, so a regression in the matcher is a failing test rather
// than a quietly narrower rule.
//
// Every shape below was measured against a real `make build`: each one puts
// state.js in the shipped background bundles, and each one was invisible to
// some earlier revision of the matcher — the quoted-only regex missed the
// backtick, the dynamic import and the `from` clause; its successor missed a
// comment inside the call and a directory resolved through package.json `main`.
//
// Two shapes the rule does NOT report are pinned below as non-reports, in
// "the divergences from the build's answer": a computed specifier such as
// `require("../shared/" + "state")`, which esbuild constant-folds, and a
// symlink to the module, whose real path esbuild reports. Both put state.js in
// the shipped background bundle and both are `make build` exit 2 with
// `make lint` exit 0 (measured). Pinning them as non-reports is what makes the
// rule's stated bounds a measured description rather than a claim: if either
// starts being reported, or the matcher is widened until one is, a test says
// so. Their catch is the build's, and is pinned in
// tests/buildForbiddenInputs.test.js against the metafile that catches it.
const fs = require("fs");
const os = require("os");
const path = require("path");
const { Linter } = require("eslint");
const plugin = require("../script/lib/eslint/noStateSingletonInBackground");
const RULE = "background/no-state-singleton-in-background";
// The three files a fixture tree always has. `src/background/index.js` is
// supplied per case; the other two stand in for the real modules.
const SHARED_STATE = "const state = {};\nmodule.exports = { state };\n";
const SHARED_HOP =
"// A shared module the background legitimately imports.\n" +
"module.exports = { applyChainSwitchFields() {} };\n";
let roots = [];
function fixture(files) {
const root = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), "autistmask-state-rule-")),
);
roots.push(root);
const tree = {
"src/shared/state.js": SHARED_STATE,
"src/shared/chainSwitchFields.js": SHARED_HOP,
...files,
};
for (const [rel, source] of Object.entries(tree)) {
const abs = path.join(root, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, source);
}
return root;
}
// Run the rule exactly as eslint.config.js runs it, over a real tree: the walk
// reads its sources from disk, so a virtual RuleTester would not exercise it.
// `sourceType` is the fixture's own, not the rule's business: the walk is
// textual and never parses the files it follows. The two ESM cases below pass
// "module" only so espree can parse the fixture at all — in this repo those
// shapes are also a parse error under the commonjs config, but the rule must
// not be left depending on that.
function lintBackground(root, { sourceType = "commonjs" } = {}) {
const file = path.join(root, "src/background/index.js");
const linter = new Linter({ cwd: root });
return linter.verify(
fs.readFileSync(file, "utf8"),
{
plugins: { background: plugin },
languageOptions: { ecmaVersion: 2024, sourceType },
rules: { [RULE]: "error" },
},
file,
);
}
function chainOf(messages) {
expect(messages).toHaveLength(1);
expect(messages[0].ruleId).toBe(RULE);
// "...singleton: <chain>. The MV3 worker..." — the chain is what the
// message exists to hand the reader, so assert on it rather than on the
// fact that something was reported.
return messages[0].message.split("singleton: ")[1].split(". The MV3")[0];
}
afterEach(() => {
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
roots = [];
});
describe("the specifier syntaxes the matcher is known to catch", () => {
test("a quoted require", () => {
const root = fixture({
"src/background/index.js":
'const { state } = require("../shared/state");\n' +
"module.exports = { state };\n",
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
test("a backtick require", () => {
const root = fixture({
"src/background/index.js":
"const { state } = require(`../shared/state`);\n" +
"module.exports = { state };\n",
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
test("a dynamic import inside an async function", () => {
const root = fixture({
"src/background/index.js":
"async function readState() {\n" +
' const m = await import("../shared/state");\n' +
" return m.state;\n" +
"}\n" +
"module.exports = { readState };\n",
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
test("a static import from-clause", () => {
const root = fixture({
"src/background/index.js":
'import { state } from "../shared/state";\n' +
"export { state };\n",
});
expect(chainOf(lintBackground(root, { sourceType: "module" }))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
// `import(/* webpackChunkName: "x" */ "./x")` is the standard bundler
// annotation idiom, and prettier leaves both of these exactly as written,
// so nothing else in the repo would object to them either.
test("a comment between the paren and the specifier", () => {
const root = fixture({
"src/background/index.js":
'globalThis.__probe = require(/* probe */ "../shared/state").state;\n',
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
test("a comment between the specifier and the closing paren", () => {
const root = fixture({
"src/background/index.js":
'globalThis.__probe = require("../shared/state" /* probe */).state;\n',
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
test("a bare side-effect import", () => {
const root = fixture({
"src/background/index.js": 'import "../shared/state";\n',
});
expect(chainOf(lintBackground(root, { sourceType: "module" }))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
});
describe("reachability, not just the direct specifier", () => {
// The shape a no-restricted-imports could never see: no background file
// names state.js, and the singleton is in the bundle anyway. In a backtick
// require, so this fails on the specifier widening as well as on the walk.
test("a two-hop re-export through a shared module", () => {
const root = fixture({
"src/background/index.js":
'const { applyChainSwitchFields } = require("../shared/chainSwitchFields");\n' +
"module.exports = { applyChainSwitchFields };\n",
"src/shared/chainSwitchFields.js":
SHARED_HOP +
"module.exports.state = require(`./state`).state;\n",
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/chainSwitchFields.js" +
" -> src/shared/state.js",
);
});
// Resolution, not syntax: the specifier names a directory, and the file it
// resolves to is chosen by that directory's package.json `main`. A walk
// that only tries `<dir>/index.js` stops on a specifier it matched.
test("a directory resolved through its package.json main", () => {
const root = fixture({
"src/background/index.js":
'globalThis.__probe = require("../shared/probepkg").state;\n',
"src/shared/probepkg/package.json": '{"main": "./bridge.js"}\n',
"src/shared/probepkg/bridge.js":
'const { state } = require("../state");\n' +
"module.exports = { state };\n",
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/probepkg/bridge.js" +
" -> src/shared/state.js",
);
});
});
// These two are holes in the rule, and they are pinned as holes on purpose:
// the build catches both, the rule is fast feedback in front of it, and a
// written-down bound that nothing measures is how the previous three rounds of
// this change ended up with claims that were false.
describe("the divergences from the build's answer", () => {
test("a computed specifier is not reported (esbuild folds it; the build fails)", () => {
const root = fixture({
"src/background/index.js":
'globalThis.__probe = require("../shared/" + "state").state;\n',
});
expect(lintBackground(root)).toEqual([]);
});
test("a symlink to the module is not reported (esbuild reports the real path)", () => {
const root = fixture({
"src/background/index.js":
'const { state } = require("../shared/stateLink");\n' +
"module.exports = { state };\n",
});
fs.symlinkSync(
path.join(root, "src/shared/state.js"),
path.join(root, "src/shared/stateLink.js"),
);
expect(lintBackground(root)).toEqual([]);
});
});
describe("what the rule must not report", () => {
test("a background file that reaches only its own state layer", () => {
const root = fixture({
"src/background/index.js":
'const { getState } = require("./state");\n' +
'const { applyChainSwitchFields } = require("../shared/chainSwitchFields");\n' +
"module.exports = { getState, applyChainSwitchFields };\n",
"src/background/state.js":
"async function getState() {}\nmodule.exports = { getState };\n",
});
expect(lintBackground(root)).toEqual([]);
});
// The tree as it actually stands. This is the assertion that would catch a
// widened matcher that resolves something it should not: it runs the rule
// over the real background entrypoint, from the real repo root.
test("the repository's own background entrypoint", () => {
const root = path.resolve(__dirname, "..");
expect(lintBackground(root)).toEqual([]);
});
});

View File

@@ -0,0 +1,366 @@
// build.js's FORBIDDEN_INPUTS assertion — the mechanical guarantee that the
// MV3 background bundle cannot contain src/shared/state.js
// (https://git.eeqj.de/sneak/AutistMask/issues/324).
//
// Why this file exists: `make check` does not run `make build`. CI executes
// the assertion (Dockerfile runs `make build`), but executing is not testing —
// invert its condition, or make the table lookup always come back undefined,
// and every check in this repo stays green while the singleton walks back into
// the worker. Five defects, one destroyed wallet, and the whole argument for
// the scoped loud-read guard rest on this assertion, so it is pinned here.
//
// The subject is build.js's exported helpers plus the table's own
// well-formedness check, driven against SYNTHETIC metafiles in esbuild's
// shape. Nothing here shells out to a build or writes dist/: the assertion's
// job is to read a metafile correctly, and a metafile is data. That the real
// shapes reach it is the build's own business and is measured in the PR that
// introduced it.
//
// Every vacuous pass this guarantee has been found to have is pinned below,
// because each one was a way for `make check`, `make lint` and `make build` to
// be green over a background bundle containing the singleton: a stale key, a
// stale module, an entry that lists no modules, an entry recorded as checked
// before its bundle was in hand, and a background entry point nobody added to
// the table.
//
// Paths are absolute on the way in, because the helpers normalize whatever
// esbuild gave them to repo-relative and this file should not depend on the
// working directory jest was started from.
const path = require("path");
const {
importChain,
newForbiddenRecord,
recordBundledInputs,
assertNoForbiddenInputs,
assertForbiddenTableCovered,
} = require("../build");
const {
BACKGROUND_ENTRY_PREFIX,
FORBIDDEN_INPUTS,
assertTableWellFormed,
} = require("../script/lib/forbiddenBundleInputs");
const ROOT = path.resolve(__dirname, "..");
const abs = (p) => path.join(ROOT, p);
const ENTRY = "src/background/index.js";
const OUT = "dist/chrome/src/background/index.js";
const STATE = "src/shared/state.js";
const HOP = "src/shared/chainSwitchFields.js";
const SECOND = "src/background/worker2.js";
const SECOND_OUT = "dist/chrome/src/background/worker2.js";
const POPUP = "src/popup/index.js";
const POPUP_OUT = "dist/chrome/src/popup/index.js";
// The real table's shape: entry point -> modules its bundle may not contain.
const TABLE = { [ENTRY]: [STATE] };
// A metafile as esbuild emits one: `outputs[out].inputs` is the flat list of
// every input that contributed to that output, and `inputs[file].imports` is
// the edge list, which is what the chain walk follows.
function metafile({ outputs = {}, imports = {} } = {}) {
return {
outputs: Object.fromEntries(
Object.entries(outputs).map(([out, inputs]) => [
abs(out),
{
inputs: Object.fromEntries(
inputs.map((input) => [
abs(input),
{ bytesInOutput: 1 },
]),
),
},
]),
),
inputs: Object.fromEntries(
Object.entries(imports).map(([file, targets]) => [
abs(file),
{ imports: targets.map((target) => ({ path: abs(target) })) },
]),
),
};
}
function check(mf, table = TABLE, record = newForbiddenRecord()) {
recordBundledInputs(mf, record);
assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, table);
return record;
}
describe("assertNoForbiddenInputs()", () => {
test("a forbidden module in the bundle fails, naming the import chain", () => {
const mf = metafile({
outputs: { [OUT]: [ENTRY, HOP, STATE] },
imports: {
[ENTRY]: [HOP],
[HOP]: [STATE],
},
});
expect(() => check(mf)).toThrow(
`${OUT} bundles ${STATE}, which ${ENTRY} must not reach: ` +
`${ENTRY} -> ${HOP} -> ${STATE}.`,
);
});
test("a bundle without the forbidden module passes, and is recorded as checked", () => {
const mf = metafile({
outputs: { [OUT]: [ENTRY, HOP, "src/background/state.js"] },
imports: { [ENTRY]: [HOP, "src/background/state.js"] },
});
const record = check(mf);
expect([...record.entriesChecked]).toEqual([ENTRY]);
expect(record.bundledInputs.has(STATE)).toBe(false);
});
test("the failure still names the bundle when no import chain can be shown", () => {
// esbuild resolves `import("../shared/" + variable)` as a glob: the
// module is an input of the output, but no single edge leads to it.
// The message must degrade to no chain rather than crash.
const mf = metafile({
outputs: { [OUT]: [ENTRY, STATE] },
imports: { [ENTRY]: [] },
});
expect(() => check(mf)).toThrow(
`${OUT} bundles ${STATE}, which ${ENTRY} must not reach.`,
);
});
// The lookup this covers is the fragile step in the whole assertion:
// repoRelative() resolves against process.cwd() and esbuild's output keys
// are cwd-relative, so a change to where the build runs from, or to
// outfile vs outdir, makes it miss. It must be loud, and the entry must
// NOT already be marked checked when it does — otherwise a later edit
// turning this throw into an early return leaves both halves of the
// guarantee satisfied by a bundle nothing looked at.
test("an output esbuild did not report fails, and records nothing as checked", () => {
const mf = metafile({
outputs: { "dist/chrome/src/background/renamed.js": [ENTRY] },
imports: { [ENTRY]: [] },
});
const record = newForbiddenRecord();
recordBundledInputs(mf, record);
expect(() =>
assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, TABLE),
).toThrow(`esbuild reported no metafile output for ${OUT}`);
expect([...record.entriesChecked]).toEqual([]);
// So even if that throw became `return`, the coverage half catches it.
record.bundledInputs.add(STATE);
expect(() => assertForbiddenTableCovered(record, TABLE)).toThrow(
`${ENTRY} is listed in FORBIDDEN_INPUTS but was not bundled`,
);
});
});
// Finding from the fifth review of this change: adding a second worker entry
// point is exactly the accident this guarantee exists for, and the person
// adding one has no reason to know a table elsewhere needs a line. So the
// default for the background directory is protected, not unprotected.
describe("background entry points are protected by default", () => {
test("a bundled background entry point with no line in the table fails", () => {
const mf = metafile({
outputs: { [SECOND_OUT]: [SECOND, STATE] },
imports: { [SECOND]: [STATE] },
});
const record = newForbiddenRecord();
recordBundledInputs(mf, record);
expect(() =>
assertNoForbiddenInputs(
abs(SECOND),
abs(SECOND_OUT),
mf,
record,
TABLE,
),
).toThrow(
`${SECOND} is a background entry point with no line in ` +
`FORBIDDEN_INPUTS`,
);
});
test("an entry point outside the background directory needs no line", () => {
// The popup legitimately bundles the singleton; that is its model.
const mf = metafile({
outputs: { [POPUP_OUT]: [POPUP, STATE] },
imports: { [POPUP]: [STATE] },
});
const record = newForbiddenRecord();
recordBundledInputs(mf, record);
expect(() =>
assertNoForbiddenInputs(
abs(POPUP),
abs(POPUP_OUT),
mf,
record,
TABLE,
),
).not.toThrow();
expect([...record.entriesChecked]).toEqual([]);
});
test("the prefix is the one the lint rule is scoped to", () => {
expect(BACKGROUND_ENTRY_PREFIX).toBe("src/background/");
expect(SECOND.startsWith(BACKGROUND_ENTRY_PREFIX)).toBe(true);
expect(POPUP.startsWith(BACKGROUND_ENTRY_PREFIX)).toBe(false);
});
});
describe("recordBundledInputs()", () => {
// The sole data source for the module half of the anti-rot check, and
// every other case here hand-seeds what it produces. Driven for real,
// across two outputs, and then handed straight to the check that reads it.
test("records every input of every output, satisfying the module half", () => {
const mf = metafile({
outputs: {
[OUT]: [ENTRY, HOP],
[POPUP_OUT]: [POPUP, STATE],
},
imports: { [ENTRY]: [HOP], [POPUP]: [STATE] },
});
const record = newForbiddenRecord();
recordBundledInputs(mf, record);
expect([...record.bundledInputs].sort()).toEqual(
[ENTRY, HOP, POPUP, STATE].sort(),
);
// Nothing hand-seeded: the covered check passes on what the recorder
// actually collected, so a recorder that collects nothing fails here.
assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, TABLE);
expect(() => assertForbiddenTableCovered(record, TABLE)).not.toThrow();
});
});
describe("importChain()", () => {
test("terminates on a cyclic input graph, and still finds the module", () => {
const mf = metafile({
imports: {
[ENTRY]: [HOP],
[HOP]: ["src/shared/log.js"],
// The cycle: log <-> hop, with the target one hop past it.
"src/shared/log.js": [HOP, STATE],
},
});
expect(importChain(mf, abs(ENTRY), STATE)).toEqual([
ENTRY,
HOP,
"src/shared/log.js",
STATE,
]);
});
test("terminates and returns null when a cycle cannot reach the module", () => {
const mf = metafile({
imports: {
[ENTRY]: [HOP],
[HOP]: ["src/shared/log.js"],
"src/shared/log.js": [HOP, ENTRY],
},
});
expect(importChain(mf, abs(ENTRY), STATE)).toBeNull();
});
});
describe("assertForbiddenTableCovered()", () => {
test("the shipped table is satisfied by a build that checked it", () => {
const record = newForbiddenRecord();
record.entriesChecked.add(ENTRY);
// The popup bundle is what legitimately contains the singleton.
record.bundledInputs.add(STATE);
expect(() => assertForbiddenTableCovered(record, TABLE)).not.toThrow();
});
// The build this drives bundles the popup and no background entry point,
// because a stale key AND a bundled background entry point is now the
// stronger failure above — the entry point that is there but unlisted is
// reported by name, before the end of the build. This case is the rot that
// is left after that one: a key naming something this build never bundled.
test("a key no bundled entry point matched fails", () => {
const mf = metafile({
outputs: { [POPUP_OUT]: [POPUP] },
imports: { [POPUP]: [] },
});
const stale = { "src/background/renamed.js": [STATE] };
const record = newForbiddenRecord();
recordBundledInputs(mf, record);
assertNoForbiddenInputs(abs(POPUP), abs(POPUP_OUT), mf, record, stale);
record.bundledInputs.add(STATE);
expect(() => assertForbiddenTableCovered(record, stale)).toThrow(
"src/background/renamed.js is listed in FORBIDDEN_INPUTS but was" +
" not bundled, so nothing checked it",
);
});
test("a forbidden module this build bundled nowhere fails", () => {
// The other half of the same rot: renaming or moving the singleton
// leaves a table that names a path nothing resolves to any more, and
// every bundle then passes it vacuously.
const mf = metafile({
outputs: { [OUT]: [ENTRY] },
imports: { [ENTRY]: [] },
});
const stale = { [ENTRY]: ["src/shared/stateRenamed.js"] };
const record = check(mf, stale);
record.bundledInputs.add(STATE);
expect(() => assertForbiddenTableCovered(record, stale)).toThrow(
"src/shared/stateRenamed.js is listed in FORBIDDEN_INPUTS for" +
` ${ENTRY}, but this build bundled it nowhere`,
);
});
// The third rot, and the worst of the three: an entry with an empty list
// is checked, is bundled, names no module that could be missing, and
// prohibits nothing — in BOTH layers at once, since the rule's forbidden
// set is Object.values(table).flat().
test("an entry that lists no modules fails", () => {
const mf = metafile({
outputs: { [OUT]: [ENTRY, STATE] },
imports: { [ENTRY]: [STATE] },
});
const empty = { [ENTRY]: [] };
const record = newForbiddenRecord();
recordBundledInputs(mf, record);
assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, empty);
expect(() => assertForbiddenTableCovered(record, empty)).toThrow(
`FORBIDDEN_INPUTS["${ENTRY}"] lists no modules`,
);
});
});
describe("assertTableWellFormed()", () => {
// Runs at require time on the shipped table, in script/lib/
// forbiddenBundleInputs.js, so an empty list fails the lint run as well as
// the build — the build's own re-check cannot help a layer that never
// reaches the build.
test("the shipped table is well formed", () => {
expect(() => assertTableWellFormed(FORBIDDEN_INPUTS)).not.toThrow();
expect(Object.entries(FORBIDDEN_INPUTS).length).toBeGreaterThan(0);
});
test("an entry that lists no modules fails, naming the entry", () => {
expect(() => assertTableWellFormed({ [ENTRY]: [] })).toThrow(
`FORBIDDEN_INPUTS["${ENTRY}"] lists no modules`,
);
});
test("a table with no entries at all fails", () => {
expect(() => assertTableWellFormed({})).toThrow(
"FORBIDDEN_INPUTS is empty",
);
});
});

View File

@@ -8,10 +8,12 @@
// broadcast — because an error code alone would not distinguish a gate from
// a switch that happened and then reported a failure.
//
// The endpoint half of that issue lives in tests/networkEndpoints.test.js;
// this file mocks the state module, which that one exercises for real.
// The endpoint half of that issue 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.
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
@@ -51,29 +53,11 @@ afterEach(() => {
// ---------------------------------------------------------------------------
// Load the background worker against stubbed browser APIs, with the real
// chain-switch module behind it, and return the handles to drive it. The
// wallet state is a plain object so that a switch that DID happen is visible
// as a mutation of it, and one that did not is visible as its absence.
// chain-switch and persistence modules behind it, and return the handles to
// drive it.
function loadBackground() {
jest.resetModules();
const walletState = {
networkId: "mainnet",
rpcUrl: CUSTOM_RPC,
blockscoutUrl: MAINNET.defaultBlockscoutUrl,
networkEndpoints: {},
wallets: walletFixture(),
lastBalanceRefresh: 1,
tokenHolderCache: {},
fraudContracts: [],
};
jest.doMock("../src/shared/state", () => ({
state: walletState,
loadState: jest.fn(async () => {}),
saveState: jest.fn(async () => {}),
currentNetwork: () => networkById(walletState.networkId),
}));
jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}),
refreshBalances: jest.fn(async () => {}),
@@ -88,12 +72,24 @@ function loadBackground() {
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_HOSTNAME] },
deniedSites: {},
};
const storage = makeStorageStub({ autistmask: persisted });
let messageListener = null;
// Every message the background pushed at a content script. chainChanged
@@ -102,12 +98,7 @@ function loadBackground() {
const toTabs = [];
global.chrome = {
storage: {
local: {
get: jest.fn(async () => ({ autistmask: persisted })),
set: jest.fn(async () => {}),
},
},
storage,
runtime: {
getURL: (path) => "chrome-extension://autistmask/" + path,
onMessage: {
@@ -157,7 +148,7 @@ function loadBackground() {
return {
switchChain,
walletState,
walletState: () => storage.read("autistmask"),
chainChangedEvents: () =>
toTabs.filter((m) => m.eventName === "chainChanged"),
};
@@ -174,8 +165,8 @@ describe("wallet_switchEthereumChain is gated on the connection", () => {
// 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.
expect(bg.walletState.networkId).toBe("mainnet");
expect(bg.walletState.rpcUrl).toBe(CUSTOM_RPC);
expect(bg.walletState().networkId).toBe("mainnet");
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
expect(bg.chainChangedEvents()).toEqual([]);
});
@@ -201,7 +192,7 @@ describe("wallet_switchEthereumChain is gated on the connection", () => {
const result = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
expect(result).toEqual({ result: null });
expect(bg.walletState.networkId).toBe("sepolia");
expect(bg.walletState().networkId).toBe("sepolia");
expect(bg.chainChangedEvents()).toEqual([
{
type: "AUTISTMASK_EVENT",
@@ -217,16 +208,16 @@ describe("wallet_switchEthereumChain is gated on the connection", () => {
const result = await bg.switchChain("0x89", CONNECTED_ORIGIN);
expect(result.error.code).toBe(4902);
expect(bg.walletState.networkId).toBe("mainnet");
expect(bg.walletState().networkId).toBe("mainnet");
});
test("a switch by a connected origin keeps the user's endpoint", async () => {
const bg = loadBackground();
await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
expect(bg.walletState.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
expect(bg.walletState().rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN);
expect(bg.walletState.rpcUrl).toBe(CUSTOM_RPC);
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
});
});

View File

@@ -16,6 +16,7 @@
// first, so neither can see this.
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
@@ -64,9 +65,12 @@ afterEach(() => {
delete global.chrome;
});
// Load the background worker with the real state and chain-switch modules
// behind it, over a storage stub that actually keeps what is written — a
// wipe is only observable against storage that remembers.
// Load the background worker with the real chain-switch and persistence
// modules behind it, over a storage stub that actually keeps what is written —
// a wipe is only observable against storage that remembers — and that clones
// in both directions, as the real API does. It used to alias, so the record
// the worker held and the "stored" one were a single object; see
// tests/support/storageStub.js.
function loadColdWorker(networkId) {
jest.resetModules();
@@ -84,20 +88,13 @@ function loadColdWorker(networkId) {
registerAlarmHandlers: jest.fn(),
}));
const store = { autistmask: storedProfile(networkId) };
const storage = makeStorageStub({ autistmask: storedProfile(networkId) });
let messageListener = null;
const toTabs = [];
global.chrome = {
storage: {
local: {
get: jest.fn(async () => ({ autistmask: store.autistmask })),
set: jest.fn(async (items) => {
store.autistmask = items.autistmask;
}),
},
},
storage,
runtime: {
getURL: (path) => "chrome-extension://autistmask/" + path,
onMessage: {
@@ -147,7 +144,7 @@ function loadColdWorker(networkId) {
return {
switchChain,
persisted: () => store.autistmask,
persisted: () => storage.read("autistmask"),
chainChangedEvents: () =>
toTabs.filter((m) => m.eventName === "chainChanged"),
};

View File

@@ -0,0 +1,279 @@
// Which chain a dApp transaction is PREPARED for on a worker that has not
// loaded state.
//
// The MV3 service worker is terminated when idle — roughly 30 seconds, which
// is its normal condition — and revived by the page's own message. Nothing
// loads state at module scope, so handleSendTransaction() used to build its
// provider with `getProvider(await getRpcUrl())`: the endpoint came from
// storage and was right, and the static network hint was omitted, so
// src/shared/balances.js fell back to currentNetwork() — the unpopulated
// singleton — and answered mainnet. ethers then fixed `chainId` at 0x1.
//
// The transaction was not sent on the wrong chain: verifySignedTx() compares
// the artifact against the selected chain and refused it. So the guard held
// and the feature did not — a user on any non-mainnet network could not send
// from a dApp at all, and the error described the symptom
// (https://git.eeqj.de/sneak/AutistMask/issues/320).
//
// This drives the real balances module and the real approval preparation and
// verification. Only ethers' JsonRpcProvider is replaced, so the static
// network hint getProvider() computes is the hint the population sees.
const { Network, Wallet, Transaction } = 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 CONNECTED_HOSTNAME = "dapp.example";
const EXT_URL = "chrome-extension://autistmask/";
const SEPOLIA = networkById("sepolia");
const MAINNET = networkById("mainnet");
const NONCE = 7;
const TX_HASH = "0xfeed";
const TX_PARAMS = {
from: signer.address,
to: RECIPIENT,
value: "0x2386f26fc10000",
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_HOSTNAME] },
deniedSites: {},
trackedTokens: [],
};
}
async function settle() {
for (let i = 0; i < 60; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
});
// A worker whose only wallet state is what is in storage, with ethers'
// JsonRpcProvider replaced by a stub that answers out of the static network it
// was constructed with — which is exactly what a real staticNetwork provider
// does, and what makes the chain id on the approval screen observable here.
function loadColdWorker(networkId) {
jest.resetModules();
const constructed = [];
const broadcast = [];
jest.doMock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubJsonRpcProvider {
constructor(url, network) {
this._network = network;
constructed.push({ url, network });
}
async getNetwork() {
return this._network;
}
async getTransactionCount() {
return NONCE;
}
async estimateGas() {
return 100000n;
}
async getFeeData() {
return {
gasPrice: 2000000000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
};
}
async broadcastTransaction(raw) {
broadcast.push(raw);
return { hash: TX_HASH };
}
}
return { ...actual, JsonRpcProvider: StubJsonRpcProvider };
});
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(),
}));
const storage = makeStorageStub({ autistmask: storedProfile(networkId) });
let messageListener = null;
const createdUrls = [];
global.chrome = {
storage,
runtime: {
getURL: (path) => EXT_URL + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (opts, cb) => {
createdUrls.push(opts.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;
messageListener(msg, sender, (r) => {
result = r;
});
return () => result;
}
return {
send,
constructed,
broadcast,
fromPopup: { url: EXT_URL + "src/popup/index.html" },
// The first message this worker ever sees, as the injected provider
// sends it.
sendTransaction: () =>
send(
{
type: "AUTISTMASK_RPC",
method: "eth_sendTransaction",
params: [TX_PARAMS],
},
{ origin: CONNECTED_ORIGIN },
),
approvalId: () => {
const url = createdUrls[createdUrls.length - 1];
return url
? new URL(url, EXT_URL).searchParams.get("approval")
: null;
},
};
}
// What the approval window does: fetch the approval and sign the transaction
// it was handed, exactly as given.
function signApproved(approvedTx) {
const tx = {};
for (const [key, value] of Object.entries(approvedTx)) {
if (key === "from") continue;
tx[key] = value;
}
return signer.signTransaction(tx);
}
describe("a dApp transaction prepared by a worker that never loaded state", () => {
test("a cold send on Sepolia reaches the approval screen and goes out", async () => {
const bg = loadColdWorker("sepolia");
const answer = bg.sendTransaction();
await settle();
// The provider was built for Sepolia, endpoint and static hint
// together. Omitting the hint made this mainnet.
expect(bg.constructed).toHaveLength(1);
expect(bg.constructed[0].url).toBe(SEPOLIA.defaultRpcUrl);
expect(bg.constructed[0].network.chainId).toBe(
Network.from("sepolia").chainId,
);
// So the approval the user is shown is a Sepolia transaction.
const id = bg.approvalId();
expect(id).toBeTruthy();
const approval = bg.send(
{ type: "AUTISTMASK_GET_APPROVAL", id },
{ url: bg.fromPopup.url },
)();
expect(approval.type).toBe("tx");
expect(approval.approvedTx.chainId).toBe(SEPOLIA.chainId);
// And it survives the wallet's own verification, which is where a
// 0x1-stamped artifact was refused as "for a different network".
const rawSignedTx = await signApproved(approval.approvedTx);
const response = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx,
},
{ url: bg.fromPopup.url },
);
await settle();
expect(response()).toEqual({ txHash: TX_HASH });
expect(bg.broadcast).toEqual([rawSignedTx]);
expect(Number(Transaction.from(rawSignedTx).chainId)).toBe(
Number(SEPOLIA.networkVersion),
);
expect(answer()).toEqual({ result: TX_HASH });
});
test("a cold send on mainnet is prepared for mainnet", async () => {
// The stored value and the old fallback agree here, so this case
// cannot catch the defect; it is what keeps the fix from being a swap.
const bg = loadColdWorker("mainnet");
bg.sendTransaction();
await settle();
expect(bg.constructed[0].url).toBe(MAINNET.defaultRpcUrl);
const approval = bg.send(
{ type: "AUTISTMASK_GET_APPROVAL", id: bg.approvalId() },
{ url: bg.fromPopup.url },
)();
expect(approval.approvedTx.chainId).toBe(MAINNET.chainId);
});
});

View File

@@ -19,6 +19,14 @@ const {
balanceWarningHtml,
} = require("../src/popup/views/deleteAddress");
const { prices, clearPrices } = require("../src/shared/prices");
const { state } = require("../src/shared/state");
// The screen prices holdings, and pricing asks which chain it is on. Reading
// the singleton's network before anything loaded it now throws rather than
// answering mainnet by default
// (https://git.eeqj.de/sneak/AutistMask/issues/324), so the network this
// fixture is on is stated instead of assumed.
state.networkId = "mainnet";
const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";

View File

@@ -13,13 +13,15 @@
// never persisting it looks identical from `state`, and a build that never
// wrote at all would pass a check that only reads `state` back.
//
// That makes the storage stub load-bearing, so it is a real store that
// structured-clones on both `set` and `get`. A stub whose `get` hands back
// the same object its `set` was given aliases the caller's own array: the
// test then reads its own in-memory mutation and calls it persistence, and
// passes against a build that persists nothing (see issue #324). The
// aliasing is closed off explicitly by the first test below rather than
// left as an assumption about `structuredClone`.
// That makes the storage stub load-bearing, so it is the shared one from
// tests/support/storageStub.js, a real store that structured-clones on both
// `set` and `get`. A stub whose `get` hands back the same object its `set`
// was given aliases the caller's own array: the test then reads its own
// in-memory mutation and calls it persistence, and passes against a build
// that persists nothing
// (https://git.eeqj.de/sneak/AutistMask/issues/324). The aliasing is closed
// off explicitly by the first test below rather than left as an assumption
// about `structuredClone`.
//
// The view is driven against a minimal DOM stub, in the same shape as
// tests/exportPrivkey.test.js: the module reads and writes named nodes and
@@ -34,6 +36,7 @@ jest.mock("../src/shared/vault", () => ({
}));
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
const { makeStorageStub } = require("./support/storageStub");
const VIEW = "delete-wallet-lost-password";
@@ -94,35 +97,6 @@ function makeDocument() {
};
}
// --------------------------------------------------------- storage stub
// A store that behaves the way `chrome.storage.local` does: what goes in is
// serialized, so the caller keeps no handle on what came to rest there, and
// what comes out is a fresh object the caller may mutate freely.
function makeStorage() {
let store = {};
return {
get: async (keys) => {
const wanted =
keys === undefined || keys === null
? Object.keys(store)
: [].concat(keys);
const out = {};
for (const key of wanted) {
if (key in store) out[key] = structuredClone(store[key]);
}
return out;
},
set: async (items) => {
for (const [key, value] of Object.entries(items)) {
store[key] = structuredClone(value);
}
},
// Test-only: what the extension would find on a cold start.
_raw: () => structuredClone(store),
};
}
// ------------------------------------------------------------ harness
function wallet(name, secret, addresses) {
@@ -144,10 +118,10 @@ function load() {
jest.resetModules();
mockSettingsShow.mockClear();
const storage = makeStorage();
const storage = makeStorageStub();
const sent = [];
globalThis.chrome = {
storage: { local: storage },
storage: { local: storage.local },
runtime: { sendMessage: (msg) => sent.push(msg) },
};
globalThis.document = makeDocument();
@@ -205,7 +179,7 @@ async function openLostPassword(deleteWallet, walletIdx) {
// every other test in this file against a build that never writes.
describe("the storage stub", () => {
test("does not hand back the object it was given", async () => {
const storage = makeStorage();
const storage = makeStorageStub();
const written = { wallets: [{ name: "Wallet 1" }] };
await storage.set({ autistmask: written });
@@ -393,8 +367,8 @@ describe("deleting without the password", () => {
// The deleted wallet's secret is gone from storage entirely, not
// merely unreferenced by the wallet list.
expect(JSON.stringify(storage._raw())).not.toContain("secret-two");
expect(JSON.stringify(storage._raw())).not.toContain("xpub-Wallet 2");
expect(JSON.stringify(storage.read())).not.toContain("secret-two");
expect(JSON.stringify(storage.read())).not.toContain("xpub-Wallet 2");
});
test("only the deleted wallet's site permissions are dropped", async () => {
@@ -462,7 +436,7 @@ describe("deleting without the password", () => {
expect(saved.activeAddress).toBeNull();
expect(saved.allowedSites).toEqual({});
expect(state.currentView).toBe("welcome");
expect(JSON.stringify(storage._raw())).not.toContain("secret-one");
expect(JSON.stringify(storage.read())).not.toContain("secret-one");
});
});

View File

@@ -10,6 +10,7 @@
// happened.
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
@@ -34,25 +35,19 @@ function walletFixture() {
// saveState() wrote — so a case can reload a fresh module from the bytes an
// earlier one persisted, which is what an extension restart does. `state` is
// a module-level singleton, so the registry has to be reset per load.
// The stub clones in both directions, as the real chrome.storage.local does.
// It used to alias, and written() then handed the NEXT module load the live
// in-memory object of the previous one as its "persisted bytes" — an extension
// restart that never crossed a serialization boundary. See
// tests/support/storageStub.js.
function loadModuleWith(persisted) {
jest.resetModules();
let written = null;
global.chrome = {
storage: {
local: {
get: jest.fn(async () =>
persisted ? { autistmask: persisted } : {},
),
set: jest.fn(async (items) => {
written = items.autistmask;
}),
},
},
};
const storage = makeStorageStub(persisted ? { autistmask: persisted } : {});
global.chrome = { storage };
return {
mod: require("../src/shared/state"),
chainSwitch: require("../src/shared/chainSwitch"),
written: () => written,
written: () => storage.read("autistmask"),
};
}

View File

@@ -9,6 +9,8 @@
const fs = require("fs");
const path = require("path");
const { makeStorageStub } = require("./support/storageStub");
const POPUP_HTML = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "index.html"),
"utf8",
@@ -51,19 +53,17 @@ describe("the UTC Timestamps checkbox placement", () => {
});
describe("the UTC Timestamps setting round-trips through storage", () => {
let store;
let storage;
// The stub clones in both directions, as the real chrome.storage.local
// does. It used to alias, which is fatal to a round-trip test in
// particular: the object the module holds and the object "storage" holds
// are then the same object, so the setting appears to have been persisted
// and read back on a build where neither happened. See
// tests/support/storageStub.js.
function loadStateModule() {
store = {};
global.chrome = {
storage: {
local: {
get: async (key) =>
key in store ? { [key]: store[key] } : {},
set: async (obj) => Object.assign(store, obj),
},
},
};
storage = makeStorageStub();
global.chrome = { storage };
jest.resetModules();
return require("../src/shared/state");
}
@@ -86,12 +86,18 @@ describe("the UTC Timestamps setting round-trips through storage", () => {
// What the change handler in views/settings.js does.
first.state.utcTimestamps = true;
await first.saveState();
expect(store.autistmask.utcTimestamps).toBe(true);
expect(storage.read("autistmask").utcTimestamps).toBe(true);
// A fresh popup load sees it.
// A fresh popup load sees it — and, before that load, refuses to
// answer at all rather than reporting the default. That refusal is
// what makes the assertion below evidence of a read from storage
// instead of a value that was already sitting in memory
// (https://git.eeqj.de/sneak/AutistMask/issues/324).
jest.resetModules();
const second = require("../src/shared/state");
expect(second.state.utcTimestamps).toBe(false);
expect(() => second.state.utcTimestamps).toThrow(
second.StateNotLoadedError,
);
await second.loadState();
expect(second.state.utcTimestamps).toBe(true);
});

View File

@@ -4,23 +4,24 @@ function oneWallet() {
return [{ name: "Wallet 1", type: "hd", addresses: [ADDRESS] }];
}
const { makeStorageStub } = require("./support/storageStub");
// state.js resolves the storage API at require time, so the stub has to exist
// before the module is loaded, and the module registry has to be reset between
// cases because `state` is a module-level singleton.
//
// The stub clones in both directions, as the real chrome.storage.local does —
// see tests/support/storageStub.js for why an aliasing one made this file
// assert less than it appears to.
function loadModuleWith(persisted) {
jest.resetModules();
const set = jest.fn(async () => {});
global.chrome = {
storage: {
local: {
get: jest.fn(async () =>
persisted ? { autistmask: persisted } : {},
),
set,
},
},
const storage = makeStorageStub(persisted ? { autistmask: persisted } : {});
global.chrome = { storage };
return {
mod: require("../src/shared/state"),
set: storage.set,
stored: () => storage.read("autistmask"),
};
return { mod: require("../src/shared/state"), set };
}
afterEach(() => {

View File

@@ -10,32 +10,12 @@
//
// Both cases below drive the real state.js module through two independent
// module registries sharing one storage backend, the way two real extension
// pages share one chrome.storage.local. The storage stub structured-clones
// on both get and set — a stub that hands back the object it was given
// aliases the caller's own mutation and would make this entire defect class
// invisible (see https://git.eeqj.de/sneak/AutistMask/issues/324).
// pages share one chrome.storage.local. The shared stub structured-clones on
// both get and set — a stub that hands back the object it was given aliases
// the caller's own mutation and would make this entire defect class invisible
// (see https://git.eeqj.de/sneak/AutistMask/issues/324).
function makeStorage() {
let store = {};
return {
get: async (keys) => {
const wanted =
keys === undefined || keys === null
? Object.keys(store)
: [].concat(keys);
const out = {};
for (const key of wanted) {
if (key in store) out[key] = structuredClone(store[key]);
}
return out;
},
set: async (items) => {
for (const [key, value] of Object.entries(items)) {
store[key] = structuredClone(value);
}
},
};
}
const { makeStorageStub } = require("./support/storageStub");
// One extension page: a fresh module registry over the shared storage.
// state.js resolves the storage API at require time, so the stub has to be
@@ -43,7 +23,7 @@ function makeStorage() {
// singleton, so each page needs its own registry to hold its own copy.
function loadPage(storage) {
jest.resetModules();
globalThis.chrome = { storage: { local: storage } };
globalThis.chrome = { storage: { local: storage.local } };
return {
state: require("../src/shared/state"),
helpers: require("../src/popup/views/helpers"),
@@ -118,7 +98,7 @@ describe("a save from a page that never saw a wallet another page added", () =>
// a save from a second page loaded before that wallet existed. Both
// wallets must survive.
test("both wallets are in storage afterwards", async () => {
const storage = makeStorage();
const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1] } });
// Loaded while storage held only Wallet 1, and never reloads —
@@ -171,7 +151,7 @@ describe("the approval-window reproduction", () => {
test("the wallet added in the popup survives confirming the approval", async () => {
globalThis.document = makeDocument();
const storage = makeStorage();
const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1] } });
// The background opens the approval window on the approve-tx
@@ -223,7 +203,7 @@ describe("the approval-window reproduction", () => {
// membership" collided as the same field.
describe("background refresh racing a wallet added on another page", () => {
test("the wallet added elsewhere survives background's stale balance save", async () => {
const storage = makeStorage();
const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1] } });
// "background": loads first, and its save is the one that lands
@@ -263,7 +243,7 @@ describe("background refresh racing a wallet added on another page", () => {
describe("background refresh racing a wallet deleted on another page", () => {
test("the wallet deleted elsewhere stays deleted after background's stale balance save", async () => {
const storage = makeStorage();
const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1, W2] } });
const background = loadPage(storage);
@@ -324,7 +304,7 @@ function revokeSite(pageState, hostname) {
describe("a dApp approval racing a stale Settings page's later save", () => {
test("the fresh approval survives Settings revoking an unrelated site", async () => {
const storage = makeStorage();
const storage = makeStorageStub();
await storage.set({
autistmask: {
wallets: [W1],
@@ -362,7 +342,7 @@ describe("a dApp approval racing a stale Settings page's later save", () => {
describe("a revoked site permission against a stale page's later save", () => {
test("the revocation holds even when the stale page approves something else", async () => {
const storage = makeStorage();
const storage = makeStorageStub();
await storage.set({
autistmask: {
wallets: [W1],
@@ -413,7 +393,7 @@ function legacyWallet(name, secret) {
describe("two wallets independently created with a colliding identity", () => {
test("both survive, encryptedSecret included, instead of one silently replacing the other", async () => {
const storage = makeStorage();
const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1] } });
// Both pages load before either has created their malformed wallet,

View File

@@ -0,0 +1,73 @@
// A chrome.storage.local stub that behaves like the real one.
//
// The real extension storage API is a serialization boundary: `set` writes a
// structured clone of what it is given, and `get` hands back a structured
// clone of what is stored. Nothing an extension page holds is ever the object
// storage holds.
//
// A stub that skips the clone aliases them together, and that hides an entire
// class of defect rather than merely being imprecise. loadState() assigns
// nested references straight out of the get result, so over an aliasing stub a
// test can assert "the endpoint was persisted" and pass on a build that never
// called saveState() at all: the in-memory mutation IS the stored record.
// Measured, not theorised — with an aliasing `get` restored over the handler
// fixed in https://git.eeqj.de/sneak/AutistMask/pulls/319, the whole suite
// passed 794/794 (https://git.eeqj.de/sneak/AutistMask/issues/324).
//
// So every test that drives real persistence uses this, and nothing rebuilds
// a storage stub by hand.
// `initial` is the starting contents, keyed as storage is: { autistmask: {...} }.
// `onOp` runs before each operation, for a test that needs to advance a clock
// or count round trips.
function makeStorageStub(initial, onOp) {
const store = initial ? structuredClone(initial) : {};
const tick = onOp || (() => {});
const get = jest.fn(async (key) => {
tick();
if (key === undefined || key === null) return structuredClone(store);
const keys = Array.isArray(key) ? key : [key];
const out = {};
for (const k of keys) {
if (Object.prototype.hasOwnProperty.call(store, k)) {
out[k] = structuredClone(store[k]);
}
}
return out;
});
const set = jest.fn(async (items) => {
tick();
for (const k of Object.keys(items)) {
store[k] = structuredClone(items[k]);
}
});
const remove = jest.fn(async (key) => {
tick();
for (const k of Array.isArray(key) ? key : [key]) delete store[k];
});
return {
// Drop this straight in as chrome.storage.
local: { get, set, remove },
get,
set,
remove,
// What is stored, cloned on the way out: a test can neither observe a
// later write through an object it read nor reach into the store by
// mutating one.
read: (key) =>
key === undefined
? structuredClone(store)
: structuredClone(store[key]),
// Seed or replace a record without going through the module under
// test — for standing in as "another page wrote this".
write: (key, value) => {
store[key] = structuredClone(value);
},
};
}
module.exports = { makeStorageStub };

View File

@@ -682,6 +682,7 @@ describe("surface 3: the balance list", () => {
"https://rpc.example.invalid",
BLOCKSCOUT,
[],
"mainnet",
);
expect(addr.balance).toBe("1.2345");
expect(addr.tokenBalances).toEqual([]);

View File

@@ -30,8 +30,11 @@ global.fetch = jest.fn(() => {
});
// state.js reads chrome.storage.local at module load; stub it so the
// default settings can be asserted against what the README promises.
global.chrome = { storage: { local: {} } };
// default settings can be asserted against what the README promises. Empty
// storage, so a load produces exactly the defaults.
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub() };
const {
fetchRecentTransactions,
@@ -745,6 +748,16 @@ describe("dust threshold filtering", () => {
});
describe("filter defaults promised by the README and Settings", () => {
// The defaults are what a load of empty storage produces, so the load is
// part of the assertion rather than an incantation before it: reading the
// singleton before any load now throws (StateNotLoadedError), because a
// context served DEFAULT_STATE without asking for it is the whole subject
// of https://git.eeqj.de/sneak/AutistMask/issues/324. The stub at the top
// of this file has storage empty.
beforeAll(async () => {
await require("../src/shared/state").loadState();
});
test("all four toggles default to on and the threshold to 100,000 gwei", () => {
expect(state.hideSpoofedSymbols).toBe(true);
expect(state.hideLowHolderTokens).toBe(true);

View File

@@ -96,18 +96,14 @@ global.document = {
global.window = { location: { search: "" } };
const stored = {};
global.chrome = {
storage: {
local: {
set: (obj) => {
Object.assign(stored, obj);
return Promise.resolve();
},
get: () => Promise.resolve(stored),
},
},
};
// Clones in both directions, as the real chrome.storage.local does; the stub
// here used to hand back the live stored object, so an in-memory mutation
// looked like a write that had reached storage. See
// tests/support/storageStub.js.
const { makeStorageStub } = require("./support/storageStub");
const storage = makeStorageStub();
global.chrome = { storage };
const txStatus = require("../src/popup/views/txStatus");
const { state } = require("../src/shared/state");