Files
AutistMask/tests/support/popupBoot.js
clawbot 19a84a5aae
All checks were successful
check / check (push) Successful in 42s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 31s
fix: floor the persisted fields a restore dereferences, and make each field's floor an executable claim (closes #362)
A persisted container was checked while its ENTRIES were dereferenced
unchecked. A stored `{"0x…": "notalist"}` in allowedSites passes the state
gate, renders a working popup, and then throws inside saveState()'s per-
hostname merge, so every save from that moment on fails while the UI looks
entirely healthy. deniedSites has the identical shape; fraudContracts is the
same class with a milder consequence.

The sweep for that class found four more:

- selectedToken, dereferenced as text behind a truthiness-only restore gate.
- rpcUrl, handed whole to `new JsonRpcProvider()` by getProvider(), which
  throws SYNCHRONOUSLY for a non-string — from txStatus.js and addWallet.js,
  neither inside a try, and the first reachable from a stored
  `currentView: "wait-tx"` through the unguarded restoreView().
- The ENTRIES of viewData. Four restore branches gate on one truthy field and
  hand the rest to a renderer that calls address.toLowerCase(): a stored
  `{"currentView":"success-tx","viewData":{"hash":"0x1"}}` throws out of
  restoreView(), skipping the rest of popup init.
- selectedWallet / selectedAddress. `wallets` is a real Array, so a stored
  "map", "length", "constructor" or "__proto__" is TRUTHY: hasValidAddress()'s
  `&&` does not short-circuit and `.addresses[…]` throws. A stale INTEGER index
  is the safe case.

Floors, in src/shared/persistedState.js: allowedSites/deniedSites through
siteMap(), fraudContracts and each hostname list through textList(),
selectedToken and activeAddress as text-or-null, rpcUrl and blockscoutUrl as
non-empty text, selectedWallet and selectedAddress as a non-negative integer
or null, and each networkEndpoints pair's two URL fields — which
applyChainSwitchFields() assigns straight onto s.rpcUrl on the next switch.

Guards, in src/popup/viewRouter.js: the four restore branches that gate on one
truthy field now check the entries their renderer dereferences, as
txStatus.restoreWait() has always done for wait-tx. "confirm-tx" joins
ADDRESS_VIEWS, because its Sign button dereferences
state.wallets[state.selectedWallet] behind no guard of its own.

A stored own "__proto__" key is dropped by siteMap(): it can never be a wallet
address, so it grants nothing, and keeping it only keeps a value the next save
would hand to the prototype setter. networkEndpoints keeps unknown keys by
design, so mergeMapByKey() in src/shared/state.js now writes with
defineProperty as well — the guard in the floor was being undone one layer
downstream.

A save that fails is also told, not merely repaired: onSaveFailure() reports
every failed save, awaited or not (the save queue's own rejection handler is
what made a failure vanish), and the popup raises a persistent "NOT SAVED"
banner naming the reason. doRefreshAndRender() no longer rejects, since every
one of its call sites fires it and walks away.

The per-field justification in the header of src/shared/stateSchema.js is
replaced by tests/persistedFieldContract.test.js. That comment shipped a false
claim in three consecutive changes; the artifact was the problem. The test is
one row per persisted field, declaring the property that field's floor is
claimed to have and PROVING it by driving the real code with hostile values —
the gate for a field the gate refuses, normalizePersisted() for a field it
floors, the real JsonRpcProvider constructor for rpcUrl, and — for every field
whose only defence is that nothing dereferences it structurally — a boot of the
real popup entry point over that value onto EVERY view the popup can reopen
onto.

That last part is what makes the claim falsifiable, and it is why this defect
class is worth a harness at all: it lives on the RESTORE path and not on Home.
So the suite goes red on a field one of those boots corrupts and a restorable
view dereferences on its render, at EITHER POLARITY — a value nothing in src/
writes is a wrong-typed one and therefore truthy, so every such field is also
driven falsy, or proven unable to be falsy after the floor. Without that, a
dereference behind `if (!state.x)` is never reached on the very boot that
corrupts x, and for three of these fields the falsy answer is the DEFAULT_STATE
default: the branch every ordinary install takes. It goes red too on a field
that gains a floor while its row still claims it has none, and on a field added
to PERSISTED_FIELDS with no row.

Two things it does not drive, and the artifacts say so rather than claiming
total coverage: a MIX of polarities, since one boot puts every corrupted field
on the same slot, so a branch reached only when one is truthy and another falsy
is not entered; and whatever no stored record reaches by itself — a view only
forward navigation opens, and anything behind a click. The header and the
README mirror now point at the test instead of restating it.

The boots are cheap enough to keep by construction rather than by sampling.
Every field the router itself reads is driven onto each view individually,
since a hostile value in one of those legitimately changes which view renders;
every other unfloored field is corrupted on the SAME boot, and that boot has to
land on the view it stored — so a field that does move the routing cannot hide
in the crowd, and the failure path re-boots one field at a time to name it.
That is forty-four boots instead of several hundred; the suite runs in 12.7s
against a 30s cap.

The DOM stub in tests/support/popupBoot.js gained one thing to make any of that
possible: an element's parentElement. Without it success-tx and transaction
threw on the first line that hides a field's wrapper, so neither renderer could
be booted onto at all — every boot aimed at them fell back to Home instead, and
the base profile the sweep starts from is now asserted to render each view
rather than fall back, so that cannot go unnoticed again.
2026-08-23 20:09:14 +00:00

348 lines
12 KiB
JavaScript

// Boot the REAL popup entry point over a stored record, exactly as the browser
// does: storage already holds the record, the page loads, DOMContentLoaded
// fires.
//
// Written for https://git.eeqj.de/sneak/AutistMask/issues/311 inside
// tests/stateRecovery.test.js and lifted here unchanged in substance when
// https://git.eeqj.de/sneak/AutistMask/issues/362 needed the same boot for a
// second field. Assertions about a corrupt stored profile have to be made
// through the entry point rather than against a view module: a screen that
// renders perfectly when something calls it, and that nothing calls, IS the
// defect.
//
// The DOM stub is built FROM src/popup/index.html — every id in the markup,
// with the classes the markup gives it — so "which views are visible" is
// answered against the real element set, and a screen with no markup behind it
// cannot pass.
const fs = require("fs");
const path = require("path");
const { makeStorageStub } = require("./storageStub");
const POPUP_HTML = fs.readFileSync(
path.join(__dirname, "..", "..", "src", "popup", "index.html"),
"utf8",
);
// Fixed addresses, never used for anything but these tests.
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const TOKEN_ADDRESS = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
// A profile in the shape every install in the field has it: complete, valid,
// and carrying no version field, because no build ever wrote one. The starting
// point for "and now corrupt exactly one field of it".
function unversionedValidProfile(extra) {
return {
hasWallet: true,
wallets: [
{
type: "hd",
name: "Wallet 1",
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [
{ address: ADDRESS, balance: "1.5", tokenBalances: [] },
],
},
],
activeAddress: ADDRESS,
networkId: "mainnet",
rpcUrl: "https://ethereum-rpc.publicnode.com",
blockscoutUrl: "https://eth.blockscout.com/api/v2",
allowedSites: { [ADDRESS]: ["dapp.example"] },
deniedSites: {},
trackedTokens: [],
theme: "system",
...(extra || {}),
};
}
function makeElement(id, className) {
const classes = new Set(
(className || "").split(/\s+/).filter((name) => name !== ""),
);
const el = {
id,
tagName: "DIV",
textContent: "",
value: "",
innerHTML: "",
href: "",
download: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
clicked: 0,
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
removeEventListener: () => {},
appendChild: () => {},
remove: () => {},
focus: () => {},
select: () => {},
setAttribute: (name, value) => {
el[name] = value;
},
querySelector: () => null,
querySelectorAll: () => [],
// The Receive view draws its QR onto #receive-qr through the qrcode
// package, which calls getContext("2d") and then createImageData/
// putImageData on the result. Without this the render throws from
// inside a promise the view does not await, which takes the whole node
// process down rather than failing a test — so a suite that boots onto
// Receive could not report anything.
getContext: () => ({
createImageData: (w, h) => ({
width: w,
height: h,
data: new Uint8ClampedArray(w * h * 4),
}),
putImageData: () => {},
clearRect: () => {},
}),
click: () => {
el.clicked += 1;
},
};
// src/ reaches parentElement only to hide or unhide the wrapper a field
// sits in (txStatus.js renderSuccess(), transactionDetail.js render()).
// The stub is flat — it is built from the ids in the markup, not from its
// tree — so each element gets a wrapper of its own, made on demand so this
// does not recurse. It is never registered by id, so nothing can mistake
// it for a view. Without it, success-tx and transaction throw on the first
// line that touches a wrapper and cannot be booted onto at all.
let parent = null;
Object.defineProperty(el, "parentElement", {
get() {
if (!parent) parent = makeElement(id + "-parent", "");
return parent;
},
});
return el;
}
// Every id in the markup, with the classes the markup gives it. A view the
// popup is supposed to reveal has to exist here, which means it has to exist
// in src/popup/index.html.
function idsFromHtml(html) {
const out = new Map();
const tags = html.match(/<[a-zA-Z][^>]*>/g) || [];
for (const tag of tags) {
const id = /\bid="([^"]+)"/.exec(tag);
if (!id) continue;
const cls = /\bclass="([^"]*)"/.exec(tag);
out.set(id[1], cls ? cls[1] : "");
}
return out;
}
// Ids the popup creates at runtime rather than authoring in the markup, and
// that must therefore read as ABSENT until something creates them. Answering
// with a fresh element instead would make "is the banner up?" always true.
const RUNTIME_IDS = new Set(["debug-banner", "save-failure-banner"]);
function makeDocument(html) {
const authored = idsFromHtml(html);
const els = new Map();
for (const [id, className] of authored) {
els.set(id, makeElement(id, className));
}
const created = [];
const prepended = [];
const doc = {
listeners: {},
getElementById(id) {
if (RUNTIME_IDS.has(id) && !els.has(id)) return null;
if (!els.has(id)) els.set(id, makeElement(id, ""));
return els.get(id);
},
createElement(tag) {
const el = makeElement("created-" + tag, "");
el.tagName = String(tag).toUpperCase();
created.push(el);
return el;
},
addEventListener(name, fn) {
doc.listeners[name] = doc.listeners[name] || [];
doc.listeners[name].push(fn);
},
querySelectorAll: () => [],
documentElement: makeElement("html", ""),
body: {
// Recorded, and registered under its id: a banner the popup
// prepends is on the page from then on, and a test asking for it
// by id has to find it.
prepend: (el) => {
prepended.push(el);
if (el && el.id) els.set(el.id, el);
},
appendChild: () => {},
removeChild: () => {},
},
elements: els,
authoredIds: authored,
created,
prepended,
};
return doc;
}
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
/**
* Boot the popup over `stored`.
*
* @param {*} stored the record storage holds, or undefined for a first run.
* @param {object} [options]
* @param {object} [options.storage] a storage stub from makeStorageStub(), for
* a test that needs to make writes fail or to watch the round trips.
* @returns {Promise<object>} handles onto the booted page.
*/
async function bootPopup(stored, options) {
jest.resetModules();
// The three modules that reach the network. None is on the path under
// test; all would make the suite hit the internet.
jest.doMock("../../src/shared/prices", () => ({
prices: {},
refreshPrices: jest.fn(async () => {}),
clearPrices: jest.fn(),
getPrice: () => null,
formatUsd: () => "",
formatAddressTotal: () => "",
getAddressValue: () => ({ usd: null, partial: false }),
getWalletValue: () => ({ usd: null, partial: false }),
getTotalValue: () => ({ usd: null, partial: false }),
}));
jest.doMock("../../src/shared/balances", () => ({
fetchTokenBalances: jest.fn(async () => []),
refreshBalances: jest.fn(async () => {}),
lookupTokenInfo: jest.fn(async () => null),
getProvider: () => ({}),
scanForAddresses: jest.fn(async () => []),
}));
jest.doMock("../../src/shared/transactions", () => ({
fetchRecentTransactions: jest.fn(async () => []),
filterTransactions: () => [],
}));
const storage =
(options && options.storage) ||
makeStorageStub(stored === undefined ? {} : { autistmask: stored });
const document = makeDocument(POPUP_HTML);
const reloads = [];
globalThis.chrome = {
storage: { local: storage.local },
runtime: {
sendMessage: jest.fn(async () => ({})),
getURL: (p) => "chrome-extension://autistmask/" + p,
onMessage: { addListener: () => {} },
},
};
globalThis.document = document;
globalThis.window = {
location: {
search: "",
href: "chrome-extension://autistmask/src/popup/index.html",
reload: () => reloads.push(Date.now()),
},
matchMedia: () => ({
matches: false,
addEventListener: () => {},
removeEventListener: () => {},
}),
addEventListener: () => {},
};
// The 10s refresh loop init() starts would outlive the test.
const realSetInterval = globalThis.setInterval;
globalThis.setInterval = () => 0;
require("../../src/popup/index");
const booted = [];
for (const fn of document.listeners.DOMContentLoaded || []) {
booted.push(fn());
}
// What the browser console would have shown. A throw out of init() is the
// blank popup issue 311 is about, so it is captured rather than thrown:
// the assertion that matters is what ended up on screen.
const pageErrors = [];
for (const p of booted) {
try {
await p;
} catch (e) {
pageErrors.push(String((e && e.message) || e));
}
}
await settle();
globalThis.setInterval = realSetInterval;
return {
storage,
document,
pageErrors,
reloaded: () => reloads.length,
node: (id) => document.getElementById(id),
text: (id) => {
const el = document.getElementById(id);
return el ? el.textContent : null;
},
value: (id) => document.getElementById(id).value,
hidden: (id) =>
document.getElementById(id).classList.contains("hidden"),
click: async (id) => {
const el = document.getElementById(id);
const fns = el.listeners.click || [];
for (const fn of fns) await fn();
await settle();
},
settle,
// The view ids whose section is not hidden, as the audit measured them.
visibleViews: () => {
const out = [];
for (const [id, el] of document.elements) {
if (!id.startsWith("view-")) continue;
if (!el.classList.contains("hidden")) out.push(id.slice(5));
}
return out;
},
};
}
function cleanupPopup() {
delete globalThis.chrome;
delete globalThis.document;
delete globalThis.window;
}
module.exports = {
bootPopup,
cleanupPopup,
settle,
unversionedValidProfile,
ADDRESS,
TOKEN_ADDRESS,
POPUP_HTML,
};