harden: make the background physically unable to read the shared state singleton (closes #324)
Five defects traced to one fact: src/background/index.js read and wrote the
module-level `state` singleton in src/shared/state.js, which the MV3 service
worker never populates and which answered an unpopulated read out of
DEFAULT_STATE in silence. Every previous fix added a loadState() before the
access, and that is what produced the fifth: a load detaches the objects an
in-flight handler is holding.
So the reachability goes rather than a sixth call site.
The background now has its own storage layer, src/background/state.js:
getState() is a detached, normalized per-call read, and updateState() is a
queued read-modify-write whose read is one storage round trip ahead of its
write. Nothing in the background holds an in-memory copy of the profile. The
write is the whole record, and updateState()'s header now names what that costs:
a popup write landing inside that one-round-trip window is reverted.
- Every handler takes one snapshot and answers from it, including the address
it names: activeAddressOf(s) replaced a second, later storage read that
could disagree with the first.
- wallet_switchEthereumChain applies applyChainSwitchFields() (split out of
chainSwitch.js, which keeps the singleton path for the popup) inside
updateState() instead of calling onChainSwitch() on the singleton.
- The remembered site decision is a read-modify-write, not a load-mutate-save
around a prompt the user takes seconds to answer.
- backgroundRefresh() refreshes a private copy of the wallets and applies the
balances that came back by address, so it never publishes an object other
in-flight work holds, and a wallet added or deleted during the round trip
survives its write.
- The transaction attempt takes its chain id and its endpoint from the same
snapshot. They used to come from different moments, so a chain switch
committed in between moved the endpoint under an artifact already verified
against the old chain.
getProvider(rpcUrl, networkId) now REQUIRES the network id and validates it
against networks.js. That closes the cold-worker wrong-chain send at its shape
rather than at one call site: the hint used to default to currentNetwork() off
the unpopulated singleton, so the endpoint was the user's chain and ethers
fixed chainId at 0x1, and the wallet's own verifySignedTx then refused every
non-mainnet dApp send. refreshBalances(), lookupTokenInfo(), scanForAddresses()
and resolveEnsName() carry the id through; balances.js no longer requires
state.js at all.
The prohibition is enforced mechanically, not by review, and it is enforced by
the bundler rather than by a guess at what the bundler does. The table of
modules an entry point's bundle may not contain lives in
script/lib/forbiddenBundleInputs.js — one copy, read by both layers that act on
it — and build.js's assertNoForbiddenInputs() fails the build when esbuild's
metafile reports src/shared/state.js as an input of a background bundle, naming
the import chain from the metafile's own graph. That is the resolution the
shipped bundle was built from, so no specifier syntax, no hop and no resolution
rule can slip past it; Dockerfile:42 runs make build, so it holds in CI.
A background entry point the table does not name fails the build as well. The
five defects were accidents, and so is adding a second worker entry point
without knowing that a table elsewhere needs a line for it: entry points under
src/background/ are prohibited by default and must be listed, rather than
protected only when someone remembers. That prefix is the build's only notion of
"the background", and eslint.config.js scopes the lint rule from the same
constant so the two layers cannot disagree about it.
Every way the table can rot is a failure rather than a quiet pass: a key no
bundled entry point matched, a listed module this build bundled nowhere, and an
entry that lists no modules. The second is what makes a rename of
src/shared/state.js loud instead of silently disarming the check, and it is
stronger than an existsSync() because it also fails when the module is still
there but has dropped out of every bundle. The third is refused at require time,
where the table is defined, because an empty list also empties the lint rule's
forbidden set — one character, and a plain require of the singleton in the
worker was green in make test, make lint and make build alike.
An entry is recorded as checked only once its bundle's inputs are in hand. It
used to be recorded before the output lookup that produces them, so an early
return past that point left both halves of the guarantee satisfied by a bundle
nothing had examined.
What the assertion does NOT cover is a COPY of the singleton at another path: it
is keyed by path, so a copy builds and lints clean. That is stated where the
table lives, with what the residual actually is — a copy carries the singleton's
own guard, so an unloaded read is a loud StateNotLoadedError and defects 1-3
cannot recur silently, but a copy carries loadState() too, so defects 4 and 5
(a stale read several awaits after a load, a load detaching objects an in-flight
handler is mutating) would recur over it in silence.
make check does not run make build, so the assertion is unit tested against
synthetic metafiles in tests/buildForbiddenInputs.test.js: build.js runs its
build() only as a program now and exports the checks. Executing a check in CI
is not testing it — without that file, inverting the condition leaves every
check in this repo green with the singleton back in the worker. Each vacuous
pass above has a case, including the output lookup that finds nothing, the empty
list, the unlisted second entry point, and recordBundledInputs() itself, which
every other case used to hand-seed.
A custom ESLint rule walks the CommonJS require graph from every src/background/
file and reports the same thing in the editor, before a full bundle. It reads
the same table, and it matches specifiers textually, so it is best-effort fast
feedback and not the guarantee — two earlier revisions of it shipped holes (a
template literal, a dynamic import(), a comment inside the call, a directory
resolved through package.json main). Those are covered now and pinned by
tests/backgroundStateLintRule.test.js. Two shapes it does not report are pinned
there as asserted non-reports, so the header's list of its bounds is measured
rather than claimed: a computed specifier (require("../shared/" + "state"),
which esbuild constant-folds into the bundle) and a symlink to the module
(esbuild reports the real path). Each is make lint exit 0 and make build exit 2.
Reading a persisted field of the singleton before any load now throws
StateNotLoadedError instead of serving DEFAULT_STATE.
Test stubs: chrome.storage.local is a serialization boundary, and eight files
stubbed it with an aliasing get, so the object a module held and the object
"storage" held were one object — an assertion could pass on a build that never
wrote anything. Every test that drives real persistence now goes through
tests/support/storageStub.js, which structured-clones in both directions.
closes #320
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
398
tests/backgroundStateIsolation.test.js
Normal file
398
tests/backgroundStateIsolation.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
270
tests/backgroundStateLintRule.test.js
Normal file
270
tests/backgroundStateLintRule.test.js
Normal 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([]);
|
||||
});
|
||||
});
|
||||
366
tests/buildForbiddenInputs.test.js
Normal file
366
tests/buildForbiddenInputs.test.js
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
279
tests/coldWorkerSendTransaction.test.js
Normal file
279
tests/coldWorkerSendTransaction.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
73
tests/support/storageStub.js
Normal file
73
tests/support/storageStub.js
Normal 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 };
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user