allowedSites was checked as a container while its entries were dereferenced unchecked. A stored {"0x...": "notalist"} passed the state gate and rendered a completely healthy popup, then threw "base.map is not a function" inside saveState()'s per-hostname merge, so every save from that moment on failed and the user went on operating a wallet that was persisting nothing. Measured against the previous head: the popup showed the main view with no page errors, and chrome.storage.local.set was never called at all. deniedSites has the identical shape; fraudContracts the same class with a milder consequence, throwing "(state.fraudContracts || []).map is not a function" on the send screen; and the sweep for the class turned up selectedToken, which is truthiness-gated on restore and then dereferenced as text, blanking the popup outright with "tokenId.toLowerCase is not a function".
All four now get the floor issue 311 settled -- the container AND its entries, with a malformed entry dropped -- through textList() and siteMap() beside the existing tokenRefs() in persistedState.js, rather than a third mechanism. Site-map keys are written with defineProperty for the same reason networkEndpoints' keys are: a stored own "__proto__" key would otherwise be handed to the prototype setter. The background's allowed.includes(hostname) gate is covered by the same floor, where a stored string would have answered a substring match rather than merely throwing.
A save that fails is no longer swallowed. onSaveFailure() in state.js reports every failed save, awaited or not -- the save queue has to attach a rejection handler to keep advancing, which is what made a failure disappear entirely -- and the popup raises a persistent "NOT SAVED" banner naming the reason. The popup's background refresh loop no longer turns a save failure into an unhandled rejection instead of a report. Both halves are needed: the floor only covers the causes it knows about, and storage can still fail for a quota or a revoked permission.
The field-by-field categorisation in the header of stateSchema.js, and its mirror in README.md, were re-verified against the code and moved with the change; the fields left on a loose floor now carry the reason each one is still safe. The popup boot harness moved to tests/support/popupBoot.js so the new tests drive the real entry point rather than duplicating it.
319 lines
10 KiB
JavaScript
319 lines
10 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: () => [],
|
|
click: () => {
|
|
el.clicked += 1;
|
|
},
|
|
};
|
|
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,
|
|
};
|