Files
AutistMask/tests/deleteAddress.test.js
sneak 277ec8c8f8
All checks were successful
check / check (push) Successful in 50s
e2e / e2e-chrome (push) Successful in 1m25s
e2e / e2e-firefox (push) Successful in 38s
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.

- 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: a custom ESLint rule
walks the CommonJS require graph from every src/background/ file and fails the
lint when src/shared/state.js is reachable, naming the chain. A re-export from
any shared module cannot put the singleton back in the bundle unnoticed.

The rule's matcher covers every specifier syntax esbuild resolves statically —
quoted require, backtick require, dynamic import(), and a static import/export
`from` clause — because a narrower match is not a matter of tidiness but a sixth
site the build cannot see: each of those shapes was measured to put state.js in
dist/chrome/src/background/index.js while the lint stayed clean.
tests/backgroundStateLintRule.test.js pins all of them, plus the two-hop
re-export, against a real fixture tree. A computed specifier
(require("../shared/" + "state")) is deliberately not matched: esbuild cannot
resolve it either, so it never reaches the bundle.

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
2026-08-23 14:08:14 +00:00

167 lines
7.0 KiB
JavaScript

// Tests for the copy on the address-removal confirmation (issue #162).
//
// The screen's whole job is to warn before a destructive-looking action, so
// the copy is the substance and is tested as such. Two things it must not
// get wrong: what it takes to get the address back — the app refuses both
// obvious routes — and what counts as holding something, which is any
// ERC-20 as well as ETH, at any size, including a balance that rounds to
// zero at the four decimals the balance lines render. The DOM behaviour
// around them is driven against the real popup by tests/e2e/run.js.
// helpers.js pulls in state.js, which reads chrome.storage.local at load.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { addressHoldsFunds } = require("../src/popup/views/helpers");
const {
recoveryPathText,
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";
const EMPTY = { address: "0x1", balance: "0.0000", tokenBalances: [] };
const ETH_ONLY = { address: "0x1", balance: "1.5", tokenBalances: [] };
const DUST = { address: "0x1", balance: "0.00001", tokenBalances: [] };
const TOKEN_ONLY = {
address: "0x1",
balance: "0.0000",
tokenBalances: [{ address: USDC, symbol: "USDC", balance: "2500.0" }],
};
const ZERO_TOKEN = {
address: "0x1",
balance: "0",
tokenBalances: [{ address: USDC, symbol: "USDC", balance: "0" }],
};
afterEach(() => {
clearPrices();
});
describe("what the screen says it takes to get the address back", () => {
// The screen used to promise the address "can be brought back at any
// time by importing this wallet's recovery phrase again". That import is
// refused as a duplicate for as long as the wallet is present, which it
// always is here — a wallet never gives up its last address.
test("it does not promise a re-import while the wallet is here", () => {
const text = recoveryPathText({ type: "hd" });
expect(text).not.toMatch(/at any time/);
expect(text).toContain("is refused while this wallet is still here");
});
test("it names deleting the whole wallet as the route back", () => {
expect(recoveryPathText({ type: "hd" })).toContain(
"delete the whole wallet in Settings",
);
});
// The scan after a re-import finds used addresses only, so an address
// that never saw a transaction does not come back at all. Saying so is
// the difference between a warning and a false reassurance.
test("it states the limit: only on-chain activity is found", () => {
const text = recoveryPathText({ type: "hd" });
expect(text).toContain("only finds addresses that have on-chain");
expect(text).toContain("never been used is not found by it");
});
// The screen is offered on xprv wallets too, and an xprv wallet holds no
// recovery phrase — telling its owner to import one would send them
// looking for words that do not exist.
test("an xprv wallet is told about its extended private key", () => {
const text = recoveryPathText({ type: "xprv" });
expect(text).toContain("extended private key");
expect(text).not.toContain("recovery phrase");
});
test("an HD wallet is told about its recovery phrase", () => {
const text = recoveryPathText({ type: "hd" });
expect(text).toContain("recovery phrase");
expect(text).not.toContain("extended private key");
});
});
describe("whether an address holds anything", () => {
test("ETH counts", () => {
expect(addressHoldsFunds(ETH_ONLY)).toBe(true);
});
// The case that decides the screen: no ETH at all, and $2500 of a
// stablecoin sitting at the address.
test("an ERC-20 balance counts even with no ETH", () => {
expect(addressHoldsFunds(TOKEN_ONLY)).toBe(true);
});
// 0.00001 ETH renders as "0.0000" at four decimals. It is still money.
test("an ETH balance below the displayed precision counts", () => {
expect(addressHoldsFunds(DUST)).toBe(true);
});
test("an address holding nothing does not", () => {
expect(addressHoldsFunds(EMPTY)).toBe(false);
expect(addressHoldsFunds(ZERO_TOKEN)).toBe(false);
});
test("a missing address or missing fields do not", () => {
expect(addressHoldsFunds(undefined)).toBe(false);
expect(addressHoldsFunds({ address: "0x1" })).toBe(false);
});
});
describe("the balance warning on the removal confirmation", () => {
test("an address holding nothing gets a blank line, not a warning", () => {
expect(balanceWarningHtml(EMPTY)).toBe(" ");
expect(balanceWarningHtml(ZERO_TOKEN)).toBe(" ");
});
test("an ERC-20-only address is warned about, and its token listed", () => {
const html = balanceWarningHtml(TOKEN_ONLY);
expect(html).toContain("This address holds a balance.");
expect(html).toContain("does not move or spend anything");
expect(html).toContain("USDC");
expect(html).toContain("2500.0000");
});
// The rendered line says 0.0000 for this address — that is the display
// format, shared with Home and AddressDetail — and the warning is shown
// all the same, because the balance is not zero.
test("an ETH balance that renders as 0.0000 is warned about", () => {
const html = balanceWarningHtml(DUST);
expect(html).toContain("This address holds a balance.");
expect(html).toContain("<span>0.0000</span>");
});
// The sentence must not assert an amount, because any amount it could
// assert has been rounded: "This address holds 0.0000 ETH." is what the
// rounded form produces for an address that holds real money.
test("the warning sentence asserts no rounded amount", () => {
for (const addr of [DUST, ETH_ONLY, TOKEN_ONLY]) {
expect(balanceWarningHtml(addr)).not.toMatch(
/holds [\d.]+ (ETH|USDC)/,
);
}
});
test("the USD total is shown when prices are known", () => {
prices.ETH = 2000;
prices.USDC = 1;
expect(balanceWarningHtml(TOKEN_ONLY)).toContain("Total: $2,500.00");
expect(balanceWarningHtml(ETH_ONLY)).toContain("Total: $3,000.00");
});
// getAddressValue() reports no value on testnet and before the first
// price fetch. A "Total: $0.00" there would be a lie about the holdings.
test("no USD total is shown when prices are not known", () => {
expect(balanceWarningHtml(TOKEN_ONLY)).not.toContain("Total:");
});
});