fix: floor the entries of the site maps and the fraud list, and stop a failed save from failing silently (closes #362)
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.
This commit is contained in:
28
README.md
28
README.md
@@ -1016,17 +1016,33 @@ generic `-32603` every request used to answer.
|
||||
|
||||
Every other field of the record is floored in `normalizePersisted()` rather than
|
||||
gated. That floor is a type check for the fields something dereferences
|
||||
structurally — `trackedTokens`, each address's `tokenBalances`, `networkId`,
|
||||
`networkEndpoints`, `activeAddress`, `viewStack` — and it checks the ENTRIES as
|
||||
well as the container, because `[1, 2]` is a list and `t.address` is one level
|
||||
below an `Array.isArray()`. The remaining fields get a `saved.x || default` or a
|
||||
present-or-default passthrough that takes the stored value verbatim, with no
|
||||
type check at all; which field is in which category is listed in the header of
|
||||
structurally — `trackedTokens`, each address's `tokenBalances`, `allowedSites`,
|
||||
`deniedSites`, `fraudContracts`, `viewStack`, `networkEndpoints`, plus the
|
||||
scalars `networkId`, `activeAddress` and `selectedToken` — and it checks the
|
||||
ENTRIES as well as the container, because `[1, 2]` is a list,
|
||||
`{"0x…": "notalist"}` is an object, and the dereference is one level below the
|
||||
container check. A malformed entry is dropped. The remaining fields get a
|
||||
`saved.x || default` or a present-or-default passthrough that takes the stored
|
||||
value verbatim, with no type check at all; which field is in which category, and
|
||||
why the loose floor is still enough for each of them, is listed in the header of
|
||||
`src/shared/stateSchema.js`. A truthy value of the wrong type in a field that IS
|
||||
dereferenced walks through truthiness and throws on the first read, which is the
|
||||
blank popup again by a longer route — so adding a field means choosing between
|
||||
the two by what reads it.
|
||||
|
||||
The `allowedSites` case is why the entry check is not optional. A stored
|
||||
`{"0x…": "notalist"}` is a well-formed object holding a malformed entry: it
|
||||
passed the gate, rendered a completely healthy popup, and then threw 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
|
||||
([#362](https://git.eeqj.de/sneak/AutistMask/issues/362)). A save that fails is
|
||||
now also reported rather than swallowed: `onSaveFailure()` in
|
||||
`src/shared/state.js` is called for every failed save, awaited or not, and the
|
||||
popup puts up a persistent "NOT SAVED" banner (`showSaveFailureBanner()` in
|
||||
`src/popup/views/helpers.js`). Storage can still fail for reasons no floor
|
||||
covers — a quota, a revoked permission, a record a newer build wrote — and the
|
||||
wallet must never look healthy while that is true.
|
||||
|
||||
The `networkId` check is not cosmetic: that value is an object KEY into
|
||||
`state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's
|
||||
prototype instead of an own key and the user's endpoint would silently not be
|
||||
|
||||
25
TODO.md
25
TODO.md
@@ -45,6 +45,31 @@ but the review is broader than any of them.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-08-23: A persisted container whose ENTRIES were dereferenced unchecked no
|
||||
longer reaches a `.map()` or a `.toLowerCase()`
|
||||
([#362](https://git.eeqj.de/sneak/AutistMask/issues/362)). `allowedSites` was
|
||||
the worst shape available: a stored `{"0x…": "notalist"}` passed the gate,
|
||||
rendered a completely healthy popup, and then threw inside `saveState()`'s
|
||||
per-hostname merge, so every save from that moment on failed silently and the
|
||||
user went on operating a wallet that was persisting nothing — measured as
|
||||
`chrome.storage.local.set` never being called at all. `deniedSites` has the
|
||||
same shape, `fraudContracts` the same class with a milder consequence (a
|
||||
broken send screen, since the boot path only reaches it through
|
||||
`loadHomeTxs()`, which catches), and the sweep for the class turned up
|
||||
`selectedToken`, which blanked the popup outright when restoring onto
|
||||
address-token. All four now get the floor
|
||||
[#311](https://git.eeqj.de/sneak/AutistMask/issues/311) settled — container
|
||||
AND entries, malformed entries dropped — through `textList()` and `siteMap()`
|
||||
beside the existing `tokenRefs()` in `src/shared/persistedState.js`. Site-map
|
||||
keys are written with `defineProperty` for the same reason `networkEndpoints`'
|
||||
are. Separately, a save that fails is no longer swallowed: `onSaveFailure()`
|
||||
in `src/shared/state.js` reports every failed save, awaited or not, and the
|
||||
popup raises a persistent "NOT SAVED" banner; the background refresh loop no
|
||||
longer turns a failure into an unhandled rejection instead of a report. The
|
||||
field-by-field categorisation in the header of `src/shared/stateSchema.js`,
|
||||
and its mirror in `README.md`, were re-verified against the code and moved
|
||||
with the change.
|
||||
|
||||
- 2026-08-23: A swap whose output token the calldata never named is said to be
|
||||
unknown instead of being called ETH
|
||||
([#353](https://git.eeqj.de/sneak/AutistMask/issues/353)). `tokenInfo(null)`
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
// AutistMask popup entry point.
|
||||
// Loads state, initializes views, triggers first render.
|
||||
|
||||
const { state, saveState, loadState } = require("../shared/state");
|
||||
const {
|
||||
state,
|
||||
saveState,
|
||||
onSaveFailure,
|
||||
loadState,
|
||||
} = require("../shared/state");
|
||||
const { StateUnusableError } = require("../shared/stateSchema");
|
||||
const { setRuntimeDebug } = require("../shared/log");
|
||||
const { log, setRuntimeDebug } = require("../shared/log");
|
||||
const { refreshPrices } = require("../shared/prices");
|
||||
const { refreshBalances } = require("../shared/balances");
|
||||
const {
|
||||
@@ -11,6 +16,7 @@ const {
|
||||
showView,
|
||||
updateDebugBanner,
|
||||
setBackRenderer,
|
||||
showSaveFailureBanner,
|
||||
pushCurrentView,
|
||||
goBack,
|
||||
} = require("./views/helpers");
|
||||
@@ -61,6 +67,14 @@ async function doRefreshAndRender() {
|
||||
state.lastBalanceRefresh = Date.now();
|
||||
await saveState();
|
||||
renderWalletList();
|
||||
} catch (e) {
|
||||
// Every call site fires this and walks away — the boot below, the ten
|
||||
// second interval, and eight views through ctx — so it must never
|
||||
// reject: an unhandled rejection is not a report of anything. The save
|
||||
// inside it reports its own failure through onSaveFailure() (see
|
||||
// src/shared/state.js); what is left here is a failed network round
|
||||
// trip, which the next tick retries.
|
||||
log.errorf("popup: background refresh failed:", e);
|
||||
} finally {
|
||||
refreshInFlight = false;
|
||||
}
|
||||
@@ -136,6 +150,12 @@ function fallbackView() {
|
||||
}
|
||||
|
||||
async function init() {
|
||||
// First, before anything can save: showView() saves on every navigation
|
||||
// without awaiting, so a save that fails from here on has somewhere to be
|
||||
// reported rather than being swallowed by the save queue
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
|
||||
// the approval-window branch below too, since that window saves as well.
|
||||
onSaveFailure(showSaveFailureBanner);
|
||||
try {
|
||||
await loadState();
|
||||
} catch (e) {
|
||||
|
||||
@@ -132,6 +132,38 @@ function updateDebugBanner(viewName) {
|
||||
}
|
||||
}
|
||||
|
||||
// The banner shown when a save has failed, registered as the save-failure
|
||||
// reporter by src/popup/index.js.
|
||||
//
|
||||
// Persistent and not dismissable, unlike showFlash(): what it says is true
|
||||
// until the popup is closed, and a message that clears itself after two seconds
|
||||
// is how the user goes on operating a wallet that is persisting nothing
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/362). It survives navigation
|
||||
// because it hangs off document.body rather than off a view.
|
||||
//
|
||||
// Created on demand rather than authored in index.html, the same way
|
||||
// updateDebugBanner() creates its own: it is absent from a popup where nothing
|
||||
// has failed, which is the state that must not need markup to be in.
|
||||
//
|
||||
// textContent, never innerHTML: `detail` carries an error message, which may
|
||||
// come from the browser's storage layer.
|
||||
function showSaveFailureBanner(detail) {
|
||||
let banner = document.getElementById("save-failure-banner");
|
||||
if (!banner) {
|
||||
banner = document.createElement("div");
|
||||
banner.id = "save-failure-banner";
|
||||
banner.style.cssText =
|
||||
"background:#c00;color:#fff;text-align:center;font-size:10px;padding:2px 4px;font-family:monospace;position:sticky;top:0;z-index:10000;";
|
||||
document.body.prepend(banner);
|
||||
}
|
||||
const message = (detail && (detail.message || detail.problem)) || detail;
|
||||
banner.textContent =
|
||||
"NOT SAVED — AutistMask could not write to storage, so recent" +
|
||||
" changes are not stored. Close and reopen the popup; if this keeps" +
|
||||
" happening, do not rely on anything you change now." +
|
||||
(message ? " (" + String(message) + ")" : "");
|
||||
}
|
||||
|
||||
// Callback that renders a view being navigated BACK onto. Set once by
|
||||
// index.js via setBackRenderer(), which routes the view through the same
|
||||
// per-view render and data guards restoreView() uses.
|
||||
@@ -516,6 +548,7 @@ module.exports = {
|
||||
showView,
|
||||
onViewLeave,
|
||||
updateDebugBanner,
|
||||
showSaveFailureBanner,
|
||||
setBackRenderer,
|
||||
pushCurrentView,
|
||||
goBack,
|
||||
|
||||
@@ -92,6 +92,57 @@ function tokenRefs(value) {
|
||||
);
|
||||
}
|
||||
|
||||
// A list of strings, for the fields whose entries are dereferenced as text:
|
||||
// fraudContracts (`a.toLowerCase()` in src/popup/views/send.js and
|
||||
// src/shared/transactions.js) and each address's hostname list in the site maps
|
||||
// below (`h !== host` filters, `list.includes(hostname)` in the background).
|
||||
//
|
||||
// Same rule as tokenRefs(), for the same reason: the container AND the entries,
|
||||
// with a malformed entry DROPPED rather than repaired. A number in a hostname
|
||||
// list names no site and a number in fraudContracts names no contract, so there
|
||||
// is nothing to repair either to, and the empty list is a legitimate value that
|
||||
// survives. The result is a fresh array of primitives, so it shares no
|
||||
// structure with `saved`.
|
||||
function textList(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((entry) => typeof entry === "string");
|
||||
}
|
||||
|
||||
// allowedSites / deniedSites: { [address]: [hostname, ...] }.
|
||||
//
|
||||
// The container check these had (truthy and not an array) is not the floor:
|
||||
// `{"0xabc…": "notalist"}` IS a non-array object, and the dereference is one
|
||||
// level below it. saveState() merges these maps per key and then per hostname
|
||||
// WITHIN each key, so a stored value that is not a list reaches `base.map()` in
|
||||
// mergeListByIdentity() (src/shared/state.js) and throws — after the popup has
|
||||
// rendered, which is why every save from then on failed while the UI looked
|
||||
// healthy (https://git.eeqj.de/sneak/AutistMask/issues/362). The Settings
|
||||
// revoke button (`list.filter()`), and the background's
|
||||
// `allowed.includes(hostname)` gate, dereference it the same way; on that last
|
||||
// one a stored string would also answer a SUBSTRING match, so a corrupt map
|
||||
// could widen a site permission rather than merely throw.
|
||||
//
|
||||
// An address key whose value is not a list of hostnames is dropped entirely: it
|
||||
// grants and denies nothing, and dropping it fails closed. Keys are written
|
||||
// with defineProperty for the same reason networkEndpoints' are — a stored own
|
||||
// "__proto__" key, which JSON can carry, would otherwise be handed to the
|
||||
// prototype setter and recorded nowhere.
|
||||
function siteMap(value) {
|
||||
const out = {};
|
||||
if (!isRecord(value)) return out;
|
||||
for (const address of Object.keys(value)) {
|
||||
const hostnames = textList(value[address]);
|
||||
if (hostnames.length === 0) continue;
|
||||
Object.defineProperty(out, address, {
|
||||
value: hostnames,
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Keep only the leading run of stored views the popup is willing to render.
|
||||
//
|
||||
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
|
||||
@@ -239,14 +290,8 @@ function normalizePersisted(saved) {
|
||||
typeof saved.activeAddress === "string" && saved.activeAddress !== ""
|
||||
? saved.activeAddress
|
||||
: null;
|
||||
out.allowedSites =
|
||||
saved.allowedSites && !Array.isArray(saved.allowedSites)
|
||||
? structuredClone(saved.allowedSites)
|
||||
: {};
|
||||
out.deniedSites =
|
||||
saved.deniedSites && !Array.isArray(saved.deniedSites)
|
||||
? structuredClone(saved.deniedSites)
|
||||
: {};
|
||||
out.allowedSites = siteMap(saved.allowedSites);
|
||||
out.deniedSites = siteMap(saved.deniedSites);
|
||||
out.rememberSiteChoice =
|
||||
saved.rememberSiteChoice !== undefined
|
||||
? saved.rememberSiteChoice
|
||||
@@ -279,7 +324,12 @@ function normalizePersisted(saved) {
|
||||
: 100000;
|
||||
out.utcTimestamps =
|
||||
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
|
||||
out.fraudContracts = structuredClone(saved.fraudContracts || []);
|
||||
// A list of contract addresses, floored the same way: send.js builds its
|
||||
// fraud set as `(state.fraudContracts || []).map((a) => a.toLowerCase())`
|
||||
// and filterTransactions() maps the same list through normalizeAddress(),
|
||||
// so a stored string walks through the `|| []` and a stored number walks
|
||||
// through an Array.isArray().
|
||||
out.fraudContracts = textList(saved.fraudContracts);
|
||||
out.tokenHolderCache = structuredClone(saved.tokenHolderCache || {});
|
||||
out.theme = saved.theme || "system";
|
||||
out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false;
|
||||
@@ -288,7 +338,20 @@ function normalizePersisted(saved) {
|
||||
saved.selectedWallet !== undefined ? saved.selectedWallet : null;
|
||||
out.selectedAddress =
|
||||
saved.selectedAddress !== undefined ? saved.selectedAddress : null;
|
||||
out.selectedToken = saved.selectedToken || null;
|
||||
// "ETH", or a contract address, or null — never anything else. The popup
|
||||
// restores onto "address-token" behind a truthiness check on this field and
|
||||
// then dereferences it as text (`tokenId.toLowerCase()` in
|
||||
// src/popup/views/addressToken.js, `state.selectedToken.toLowerCase()` in
|
||||
// src/popup/views/receive.js), so a stored number is truthy, passes the
|
||||
// restore gate, and throws on the screen it restores onto. Found by the
|
||||
// sweep for this same defect class in
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/362; floored to null, which
|
||||
// is what the restore gate already treats as "nothing selected". The empty
|
||||
// string was already falsy here and stays null.
|
||||
out.selectedToken =
|
||||
typeof saved.selectedToken === "string" && saved.selectedToken !== ""
|
||||
? saved.selectedToken
|
||||
: null;
|
||||
out.viewData = structuredClone(saved.viewData || {});
|
||||
out.viewStack = restorableStack(saved.viewStack, out.currentView);
|
||||
return out;
|
||||
|
||||
@@ -522,11 +522,51 @@ async function saveStateOnce() {
|
||||
// begins, so each one only ever sees the true live state at its turn.
|
||||
let saveQueue = Promise.resolve();
|
||||
|
||||
// Where a failed save is REPORTED, set once by the context that has a screen
|
||||
// to say it on (src/popup/index.js).
|
||||
//
|
||||
// A save that fails must not fail silently. showView() fires saveState() on
|
||||
// every navigation without awaiting it, and the queue below has to attach a
|
||||
// rejection handler to keep advancing — so a failing save was swallowed
|
||||
// entirely: no throw, no message, nothing on screen. The wallet kept running
|
||||
// against storage that was rejecting every write, which is the data-loss half
|
||||
// of https://git.eeqj.de/sneak/AutistMask/issues/362. The awaited callers were
|
||||
// no better off: `await saveState()` inside an unguarded event handler surfaces
|
||||
// in the console and nowhere the user looks.
|
||||
//
|
||||
// This is the "tell the user" half; the other half is the floor in
|
||||
// normalizePersisted(), which stops the malformed-record cause from arising in
|
||||
// the first place. Both, because a floor only covers the causes it knows about
|
||||
// and storage can still fail for reasons of its own (quota, a revoked
|
||||
// permission, a record a newer build wrote).
|
||||
let saveFailureHandler = null;
|
||||
|
||||
function onSaveFailure(fn) {
|
||||
saveFailureHandler = fn;
|
||||
}
|
||||
|
||||
function reportSaveFailure(err) {
|
||||
log.errorf("state: saving failed, changes were NOT persisted:", err);
|
||||
if (!saveFailureHandler) return;
|
||||
try {
|
||||
saveFailureHandler(err);
|
||||
} catch (e) {
|
||||
// The reporter is the last thing standing between a failed save and
|
||||
// silence; a reporter that throws must not become an unhandled
|
||||
// rejection of its own on top of it.
|
||||
log.errorf("state: the save-failure reporter itself failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function saveState() {
|
||||
const turn = saveQueue.then(saveStateOnce);
|
||||
// The queue must advance even when a save rejects, or every save after
|
||||
// it queues behind a promise that never settles.
|
||||
saveQueue = turn.catch(() => {});
|
||||
// Every failed save is reported, whether or not the caller awaited this
|
||||
// one. The returned promise still rejects, so a caller that DOES await
|
||||
// keeps its own error handling.
|
||||
turn.catch(reportSaveFailure);
|
||||
return turn;
|
||||
}
|
||||
|
||||
@@ -574,6 +614,7 @@ function currentAddress() {
|
||||
module.exports = {
|
||||
state,
|
||||
saveState,
|
||||
onSaveFailure,
|
||||
loadState,
|
||||
currentAddress,
|
||||
currentNetwork,
|
||||
|
||||
@@ -31,18 +31,33 @@
|
||||
// which one a given field has without reading it off:
|
||||
//
|
||||
// Type-checked, container AND entries: trackedTokens, each address's
|
||||
// tokenBalances, networkId, networkEndpoints, activeAddress, viewStack.
|
||||
// These are the fields something dereferences structurally — iterated,
|
||||
// indexed, assigned into, or .toLowerCase()'d — where a truthy value of
|
||||
// the wrong type throws on the first read. The entries matter as much as
|
||||
// the container: [1, 2] IS a list, and `t.address` is one level below the
|
||||
// Array.isArray().
|
||||
// Container shape only: allowedSites, deniedSites. A falsy value or a list
|
||||
// becomes {}; anything else is taken as stored and the entries are not
|
||||
// checked.
|
||||
// tokenBalances, allowedSites, deniedSites, fraudContracts, viewStack,
|
||||
// networkEndpoints. These are the fields something dereferences
|
||||
// structurally — iterated, indexed, assigned into, or .toLowerCase()'d —
|
||||
// where a truthy value of the wrong type throws on the first read. The
|
||||
// entries matter as much as the container: [1, 2] IS a list, {"0x…":
|
||||
// "notalist"} IS an object, and the dereference is one level below the
|
||||
// container check. A malformed entry is dropped. networkEndpoints is the
|
||||
// one whose entries are coerced rather than dropped: each value is spread
|
||||
// into a fresh record, so a stored scalar becomes a record with no
|
||||
// rpcUrl/blockscoutUrl and falls to the network defaults.
|
||||
// Type-checked scalar: networkId, activeAddress, selectedToken. Each is
|
||||
// dereferenced as text or used as an object key, so a truthy value of the
|
||||
// wrong type throws or lands somewhere it should not; each falls back to
|
||||
// its default or to null.
|
||||
// `saved.x || default`, no type check: rpcUrl, blockscoutUrl,
|
||||
// lastBalanceRefresh, fraudContracts, tokenHolderCache, theme,
|
||||
// currentView, selectedToken, viewData.
|
||||
// lastBalanceRefresh, tokenHolderCache, theme, currentView, viewData.
|
||||
// None of these is dereferenced structurally, which is why the loose
|
||||
// floor is still enough, and each reason is a fact about the readers, not
|
||||
// a promise: the two URLs are only concatenated into a fetch URL and
|
||||
// handed to ethers, where a bad value fails the request on a path that
|
||||
// already catches; lastBalanceRefresh is only arithmetic; theme and
|
||||
// currentView are only compared and concatenated (both fall through to a
|
||||
// default branch, and currentView is additionally gated by
|
||||
// RESTORABLE_VIEWS.has() before anything renders from it); viewData is
|
||||
// only read field-by-field, and a field of a string or a number is
|
||||
// undefined rather than a throw; nothing in src/ reads tokenHolderCache at
|
||||
// all — it is only ever reset wholesale.
|
||||
// Present-or-default, value taken verbatim: every boolean flag,
|
||||
// dustThresholdGwei, selectedWallet, selectedAddress.
|
||||
//
|
||||
|
||||
377
tests/persistedEntryFloors.test.js
Normal file
377
tests/persistedEntryFloors.test.js
Normal file
@@ -0,0 +1,377 @@
|
||||
// A persisted container that is checked while its ENTRIES are dereferenced
|
||||
// unchecked (https://git.eeqj.de/sneak/AutistMask/issues/362).
|
||||
//
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/311 settled the idiom — floor the
|
||||
// container AND its entries, dropping anything that cannot be safely
|
||||
// dereferenced — and applied it to trackedTokens and tokenBalances. These are
|
||||
// the fields it did not reach.
|
||||
//
|
||||
// allowedSites is the worst shape in the codebase, and it is what the boot
|
||||
// tests below measure: a stored `{"0x…": "notalist"}` passes the gate, renders
|
||||
// a WORKING popup, and then throws `base.map is not a function` inside
|
||||
// saveState()'s merge — so every save from then on fails while the UI looks
|
||||
// entirely healthy and the user goes on operating a wallet that is persisting
|
||||
// nothing. A blank popup is at least visibly broken; this is not. So the
|
||||
// assertion here is never merely "the popup rendered": it is "the popup
|
||||
// rendered AND the write actually landed in storage".
|
||||
//
|
||||
// Observed at ad6aa7b, with the floors below removed:
|
||||
// allowedSites: {"0x…": "notalist"} -> views=["main"], errors=[], and
|
||||
// storage.set NEVER called: the stored record kept no schemaVersion, so
|
||||
// nothing the user did was persisted.
|
||||
// fraudContracts: "0x…" -> renderSendTokenSelect() threw
|
||||
// "(state.fraudContracts || []).map is not a function"
|
||||
// fraudContracts: [42] -> threw "a.toLowerCase is not a
|
||||
// function"
|
||||
// selectedToken: 42 (restoring onto address-token) -> views=[], errors=
|
||||
// ["tokenId.toLowerCase is not a function"] — a blank popup.
|
||||
|
||||
const { normalizePersisted } = require("../src/shared/persistedState");
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
const {
|
||||
bootPopup,
|
||||
cleanupPopup,
|
||||
unversionedValidProfile,
|
||||
ADDRESS,
|
||||
TOKEN_ADDRESS,
|
||||
} = require("./support/popupBoot");
|
||||
|
||||
// One extension page: a fresh module registry over the given storage. state.js
|
||||
// resolves the storage API at require time, so the stub has to be installed
|
||||
// before the module is loaded.
|
||||
function loadStateModule(storage) {
|
||||
jest.resetModules();
|
||||
globalThis.chrome = { storage: { local: storage.local } };
|
||||
return require("../src/shared/state");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanupPopup();
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- the floor itself
|
||||
|
||||
describe("the floor under allowedSites and deniedSites", () => {
|
||||
for (const field of ["allowedSites", "deniedSites"]) {
|
||||
test(`${field} that is not a record becomes an empty record`, () => {
|
||||
for (const bad of ["nope", 42, true, [ADDRESS], null]) {
|
||||
expect(normalizePersisted({ [field]: bad })[field]).toEqual({});
|
||||
}
|
||||
});
|
||||
|
||||
test(`an ${field} entry whose value is not a hostname list is dropped`, () => {
|
||||
for (const bad of ["dapp.example", 42, null, { a: 1 }, true]) {
|
||||
expect(
|
||||
normalizePersisted({ [field]: { [ADDRESS]: bad } })[field],
|
||||
).toEqual({});
|
||||
}
|
||||
});
|
||||
|
||||
test(`a hostname that is not text is dropped from an ${field} entry`, () => {
|
||||
expect(
|
||||
normalizePersisted({
|
||||
[field]: { [ADDRESS]: [42, null, "dapp.example", {}] },
|
||||
})[field],
|
||||
).toEqual({ [ADDRESS]: ["dapp.example"] });
|
||||
});
|
||||
|
||||
test(`a real ${field} map survives, copied not shared`, () => {
|
||||
const saved = { [field]: { [ADDRESS]: ["dapp.example"] } };
|
||||
|
||||
const out = normalizePersisted(saved);
|
||||
|
||||
expect(out[field]).toEqual(saved[field]);
|
||||
expect(out[field]).not.toBe(saved[field]);
|
||||
expect(out[field][ADDRESS]).not.toBe(saved[field][ADDRESS]);
|
||||
});
|
||||
|
||||
test(`a good ${field} entry beside a malformed one survives`, () => {
|
||||
const out = normalizePersisted({
|
||||
[field]: { [ADDRESS]: ["dapp.example"], [TOKEN_ADDRESS]: 42 },
|
||||
});
|
||||
|
||||
expect(out[field]).toEqual({ [ADDRESS]: ["dapp.example"] });
|
||||
});
|
||||
|
||||
test(`a stored own "__proto__" key in ${field} does not become a prototype`, () => {
|
||||
// JSON can carry the key, and plain assignment would hand it to
|
||||
// the prototype setter — recording no entry and, worse, moving the
|
||||
// map's prototype. Same reason networkEndpoints uses
|
||||
// defineProperty.
|
||||
const saved = JSON.parse(
|
||||
'{"' + field + '":{"__proto__":["evil.invalid"]}}',
|
||||
);
|
||||
|
||||
const out = normalizePersisted(saved);
|
||||
|
||||
expect(Object.getPrototypeOf(out[field])).toBe(Object.prototype);
|
||||
expect(Object.keys(out[field])).toEqual(["__proto__"]);
|
||||
expect({}.length).toBeUndefined();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("the floor under fraudContracts", () => {
|
||||
test("fraudContracts that is not a list becomes an empty list", () => {
|
||||
for (const bad of ["nope", 42, true, { a: 1 }]) {
|
||||
expect(
|
||||
normalizePersisted({ fraudContracts: bad }).fraudContracts,
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("a fraudContracts entry that is not text is dropped", () => {
|
||||
expect(
|
||||
normalizePersisted({
|
||||
fraudContracts: [42, null, TOKEN_ADDRESS, {}, []],
|
||||
}).fraudContracts,
|
||||
).toEqual([TOKEN_ADDRESS]);
|
||||
});
|
||||
|
||||
test("a real fraudContracts list survives, copied not shared", () => {
|
||||
const saved = { fraudContracts: [TOKEN_ADDRESS] };
|
||||
|
||||
const out = normalizePersisted(saved);
|
||||
|
||||
expect(out.fraudContracts).toEqual(saved.fraudContracts);
|
||||
expect(out.fraudContracts).not.toBe(saved.fraudContracts);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the floor under selectedToken", () => {
|
||||
// Found by the sweep for this defect class, not named in the issue: the
|
||||
// restore gate in src/popup/viewRouter.js checks truthiness only, and both
|
||||
// src/popup/views/addressToken.js and src/popup/views/receive.js then
|
||||
// dereference it as text.
|
||||
test("a selectedToken that is not text becomes null", () => {
|
||||
for (const bad of [42, true, { a: 1 }, [TOKEN_ADDRESS]]) {
|
||||
expect(
|
||||
normalizePersisted({ selectedToken: bad }).selectedToken,
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("a real selectedToken survives; the empty string becomes null", () => {
|
||||
expect(
|
||||
normalizePersisted({ selectedToken: TOKEN_ADDRESS }).selectedToken,
|
||||
).toBe(TOKEN_ADDRESS);
|
||||
expect(normalizePersisted({ selectedToken: "ETH" }).selectedToken).toBe(
|
||||
"ETH",
|
||||
);
|
||||
expect(
|
||||
normalizePersisted({ selectedToken: "" }).selectedToken,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------- what the user actually gets
|
||||
|
||||
describe("a malformed allowedSites entry", () => {
|
||||
const MALFORMED = [
|
||||
{ name: "a string", value: "notalist" },
|
||||
{ name: "a number", value: 42 },
|
||||
{ name: "a record", value: { hostnames: ["dapp.example"] } },
|
||||
];
|
||||
|
||||
for (const { name, value } of MALFORMED) {
|
||||
test(`whose value is ${name}: a working popup whose writes persist`, async () => {
|
||||
const env = await bootPopup(
|
||||
unversionedValidProfile({
|
||||
allowedSites: { [ADDRESS]: value },
|
||||
}),
|
||||
);
|
||||
|
||||
expect({
|
||||
visibleViews: env.visibleViews(),
|
||||
errors: env.pageErrors,
|
||||
}).toEqual({ visibleViews: ["main"], errors: [] });
|
||||
|
||||
// The half that matters. A popup that renders and never persists
|
||||
// again is worse than one that renders nothing, because nothing
|
||||
// tells the user. The version stamp is proof a write landed: it
|
||||
// is absent from the stored record until saveState() writes one.
|
||||
expect(env.storage.set).toHaveBeenCalled();
|
||||
const stored = env.storage.read("autistmask");
|
||||
expect(stored.schemaVersion).toBe(1);
|
||||
expect(stored.wallets[0].encryptedSecret).toBe(
|
||||
"encrypted-secret-1",
|
||||
);
|
||||
expect(stored.allowedSites).toEqual({});
|
||||
});
|
||||
}
|
||||
|
||||
test("the well-formed entries beside it keep working", async () => {
|
||||
const env = await bootPopup(
|
||||
unversionedValidProfile({
|
||||
allowedSites: {
|
||||
[ADDRESS]: ["dapp.example"],
|
||||
[TOKEN_ADDRESS]: "notalist",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(env.pageErrors).toEqual([]);
|
||||
expect(env.storage.read("autistmask").allowedSites).toEqual({
|
||||
[ADDRESS]: ["dapp.example"],
|
||||
});
|
||||
});
|
||||
|
||||
test("a later save still lands, not just the first", async () => {
|
||||
// The failure this closes was in the MERGE, which runs on every save
|
||||
// against whatever is in storage at the time. One write landing is not
|
||||
// enough: the field has to stay mergeable.
|
||||
const storage = makeStorageStub({
|
||||
autistmask: unversionedValidProfile({
|
||||
allowedSites: { [ADDRESS]: "notalist" },
|
||||
}),
|
||||
});
|
||||
const { state, loadState, saveState } = loadStateModule(storage);
|
||||
|
||||
await loadState();
|
||||
state.theme = "dark";
|
||||
await saveState();
|
||||
state.utcTimestamps = true;
|
||||
await saveState();
|
||||
|
||||
const stored = storage.read("autistmask");
|
||||
expect(stored.theme).toBe("dark");
|
||||
expect(stored.utcTimestamps).toBe(true);
|
||||
expect(stored.allowedSites).toEqual({});
|
||||
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a malformed fraudContracts", () => {
|
||||
// The send screen, which is where this one lands: the boot path only
|
||||
// reaches fraudContracts through loadHomeTxs(), which catches, so the
|
||||
// consequence is an unusable send screen rather than silent data loss.
|
||||
function stubSendDocument() {
|
||||
const select = { innerHTML: "", children: [] };
|
||||
select.appendChild = (child) => select.children.push(child);
|
||||
globalThis.document = {
|
||||
getElementById: (id) => (id === "send-token" ? select : null),
|
||||
createElement: () => ({ value: "", textContent: "" }),
|
||||
};
|
||||
return select;
|
||||
}
|
||||
|
||||
const HELD = {
|
||||
address: TOKEN_ADDRESS,
|
||||
symbol: "AAA",
|
||||
decimals: 18,
|
||||
balance: "12.5",
|
||||
holders: 50000,
|
||||
};
|
||||
|
||||
async function sendScreenTokens(fraudContracts) {
|
||||
const storage = makeStorageStub({
|
||||
autistmask: unversionedValidProfile({ fraudContracts }),
|
||||
});
|
||||
const { loadState } = loadStateModule(storage);
|
||||
await loadState();
|
||||
const select = stubSendDocument();
|
||||
const { renderSendTokenSelect } = require("../src/popup/views/send");
|
||||
|
||||
renderSendTokenSelect({ address: ADDRESS, tokenBalances: [HELD] });
|
||||
|
||||
return select.children.map((opt) => opt.value);
|
||||
}
|
||||
|
||||
for (const bad of ["notalist", 42, { a: 1 }, [42], [null], [{}]]) {
|
||||
test(`${JSON.stringify(bad)}: a usable send screen`, async () => {
|
||||
await expect(sendScreenTokens(bad)).resolves.toEqual([
|
||||
TOKEN_ADDRESS,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
test("a real fraud entry beside a malformed one still hides its token", async () => {
|
||||
await expect(
|
||||
sendScreenTokens([42, TOKEN_ADDRESS.toLowerCase()]),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a malformed selectedToken", () => {
|
||||
test("does not blank the popup on restore", async () => {
|
||||
const env = await bootPopup(
|
||||
unversionedValidProfile({
|
||||
currentView: "address-token",
|
||||
selectedWallet: 0,
|
||||
selectedAddress: 0,
|
||||
selectedToken: 42,
|
||||
viewStack: ["main", "address"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect({
|
||||
visibleViews: env.visibleViews(),
|
||||
errors: env.pageErrors,
|
||||
}).toEqual({ visibleViews: ["main"], errors: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------- a save that fails is told
|
||||
|
||||
describe("a save that fails", () => {
|
||||
function failingStorage(profile) {
|
||||
const storage = makeStorageStub({ autistmask: profile });
|
||||
const realSet = storage.local.set;
|
||||
storage.local.set = jest.fn(async () => {
|
||||
throw new Error("QUOTA_BYTES quota exceeded");
|
||||
});
|
||||
storage.restoreWrites = () => {
|
||||
storage.local.set = realSet;
|
||||
};
|
||||
return storage;
|
||||
}
|
||||
|
||||
test("is reported, not swallowed by the save queue", async () => {
|
||||
const storage = failingStorage(unversionedValidProfile());
|
||||
const { state, loadState, saveState, onSaveFailure } =
|
||||
loadStateModule(storage);
|
||||
const failures = [];
|
||||
onSaveFailure((e) => failures.push(String(e && e.message)));
|
||||
|
||||
await loadState();
|
||||
state.theme = "dark";
|
||||
// Not awaited, which is how showView() saves on every navigation and
|
||||
// how the failure used to disappear entirely.
|
||||
saveState();
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
|
||||
expect(failures).toEqual(["QUOTA_BYTES quota exceeded"]);
|
||||
});
|
||||
|
||||
test("still rejects for a caller that awaits it", async () => {
|
||||
const storage = failingStorage(unversionedValidProfile());
|
||||
const { state, loadState, saveState, onSaveFailure } =
|
||||
loadStateModule(storage);
|
||||
onSaveFailure(() => {});
|
||||
|
||||
await loadState();
|
||||
state.theme = "dark";
|
||||
|
||||
await expect(saveState()).rejects.toThrow("QUOTA_BYTES");
|
||||
});
|
||||
|
||||
test("puts a banner on the popup saying nothing is being saved", async () => {
|
||||
const env = await bootPopup(undefined, {
|
||||
storage: failingStorage(unversionedValidProfile()),
|
||||
});
|
||||
|
||||
// The popup is still usable — the point is that it no longer looks
|
||||
// healthy while silently persisting nothing.
|
||||
expect(env.visibleViews()).toEqual(["main"]);
|
||||
const banner = env.node("save-failure-banner");
|
||||
expect(banner).not.toBeNull();
|
||||
expect(banner.textContent).toContain("NOT SAVED");
|
||||
expect(banner.textContent).toContain("QUOTA_BYTES quota exceeded");
|
||||
});
|
||||
|
||||
test("no banner appears on a popup whose saves work", async () => {
|
||||
const env = await bootPopup(unversionedValidProfile());
|
||||
|
||||
expect(env.node("save-failure-banner")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,11 @@ const POPUP_HTML_PATH = path.join(POPUP_DIR, "index.html");
|
||||
const RUNTIME_CREATED_IDS = new Set([
|
||||
// Created by updateDebugBanner() in src/popup/views/helpers.js.
|
||||
"debug-banner",
|
||||
// Created by showSaveFailureBanner() in the same file, on the first save
|
||||
// that fails. Absent from the markup on purpose: a popup where nothing has
|
||||
// failed must not have to carry an empty banner
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/362).
|
||||
"save-failure-banner",
|
||||
]);
|
||||
|
||||
// Every id lookup the popup performs with a literal argument, as
|
||||
|
||||
@@ -13,61 +13,26 @@
|
||||
// calls, is exactly the defect: what has to be true is that BOOTING the popup
|
||||
// on a bad blob lands on it.
|
||||
//
|
||||
// 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 recovery screen with no markup
|
||||
// behind it cannot pass.
|
||||
// The boot harness and its DOM stub — built FROM src/popup/index.html, so
|
||||
// "which views are visible" is answered against the real element set — live in
|
||||
// tests/support/popupBoot.js, since tests/persistedEntryFloors.test.js needs
|
||||
// the same boot.
|
||||
//
|
||||
// The fourth case is the upgrade one, and it is the case that must NOT reach
|
||||
// the recovery screen: every install in the field has a valid profile with no
|
||||
// version field, and showing those users a wipe prompt would be a worse defect
|
||||
// than the one being fixed. It is migrated in place and keeps working.
|
||||
|
||||
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",
|
||||
);
|
||||
|
||||
// Fixed address, never used for anything but these tests.
|
||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
// A fixed ERC-20 contract address, same rule.
|
||||
const TOKEN_ADDRESS = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
const {
|
||||
bootPopup,
|
||||
cleanupPopup,
|
||||
unversionedValidProfile,
|
||||
ADDRESS,
|
||||
TOKEN_ADDRESS,
|
||||
} = require("./support/popupBoot");
|
||||
|
||||
// ------------------------------------------------------------- fixtures
|
||||
|
||||
// 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.
|
||||
function unversionedValidProfile() {
|
||||
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",
|
||||
};
|
||||
}
|
||||
|
||||
// The three blobs from the issue, each with the error it produced.
|
||||
const CORRUPT_BLOBS = [
|
||||
{
|
||||
@@ -103,235 +68,7 @@ const CORRUPT_BLOBS = [
|
||||
},
|
||||
];
|
||||
|
||||
// ------------------------------------------------------------- DOM stub
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 doc = {
|
||||
listeners: {},
|
||||
getElementById(id) {
|
||||
// Created on demand by updateDebugBanner(); absent is the state a
|
||||
// non-debug, non-testnet popup is in.
|
||||
if (id === "debug-banner") 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: {
|
||||
prepend: () => {},
|
||||
appendChild: () => {},
|
||||
removeChild: () => {},
|
||||
},
|
||||
elements: els,
|
||||
authoredIds: authored,
|
||||
created,
|
||||
};
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- harness
|
||||
|
||||
// Boot the real popup entry point over `stored`, exactly as the browser does:
|
||||
// storage already holds the record, the page loads, DOMContentLoaded fires.
|
||||
async function bootPopup(stored) {
|
||||
jest.resetModules();
|
||||
|
||||
// The two modules that reach the network. Neither is on the path under
|
||||
// test; both would make this 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 = 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 this issue 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) => document.getElementById(id).textContent,
|
||||
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();
|
||||
},
|
||||
// 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete globalThis.chrome;
|
||||
delete globalThis.document;
|
||||
delete globalThis.window;
|
||||
});
|
||||
afterEach(cleanupPopup);
|
||||
|
||||
// --------------------------------------------------------------- tests
|
||||
|
||||
|
||||
318
tests/support/popupBoot.js
Normal file
318
tests/support/popupBoot.js
Normal file
@@ -0,0 +1,318 @@
|
||||
// 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,
|
||||
};
|
||||
Reference in New Issue
Block a user