fix: version stored state, validate its shape, and give a corrupt blob a way out (closes #311)
Stored state had no version and no structural validation, so a corrupt blob produced a completely blank popup with no message and no recovery control, and made every dApp RPC call from every page answer a generic -32603. There was no reset or wipe control anywhere in the UI. saveState() now stamps a schema version and loadState() validates the shape. A version it does not understand, or a wallets array it cannot parse, lands on a recovery screen that names the problem, offers the stored record verbatim for export, and offers a destructive reset behind a typed confirmation. Unversioned but valid state -- which every existing install has -- migrates in place and keeps working; it is never shown a wipe prompt. A dApp call against unusable state answers -32007, which EIP-1474 leaves unassigned, rather than -32603. networkById() refuses an unknown id loudly instead of returning mainnet, and networkId is validated so a corrupt value cannot be used as an object key. Fields the gate does not refuse are floored by type, container and entries both: a malformed trackedTokens or tokenBalances entry is dropped rather than dereferenced. Verified by an independent sweep of 1152 corrupt blobs producing no blank popup, with the same harness showing 9 blanks against the previous revision.
This commit was merged in pull request #360.
This commit is contained in:
630
tests/stateRecovery.test.js
Normal file
630
tests/stateRecovery.test.js
Normal file
@@ -0,0 +1,630 @@
|
||||
// A stored profile the popup cannot read must produce a SCREEN, not a blank
|
||||
// popup (https://git.eeqj.de/sneak/AutistMask/issues/311).
|
||||
//
|
||||
// The three corrupt blobs below are the ones the pre-1.0 audit wrote into
|
||||
// storage. Against the build this file was added to, each one rendered nothing
|
||||
// at all — no view, no message, no control — because init() dereferenced
|
||||
// `state.wallets[0].addresses` on a record nothing had validated and threw
|
||||
// before the first showView().
|
||||
//
|
||||
// So the assertions here are deliberately made through the REAL popup entry
|
||||
// point rather than against the recovery view module directly. A recovery
|
||||
// screen that renders perfectly when something calls it, and that nothing
|
||||
// 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 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";
|
||||
|
||||
// ------------------------------------------------------------- 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 = [
|
||||
{
|
||||
name: "wallets is a string",
|
||||
blob: { hasWallet: true, wallets: ADDRESS, activeAddress: ADDRESS },
|
||||
},
|
||||
{
|
||||
name: "wallets is an array of garbage",
|
||||
blob: {
|
||||
hasWallet: true,
|
||||
wallets: [null, 42, "wallet"],
|
||||
activeAddress: ADDRESS,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "future-schema blob (unknown fields, no version)",
|
||||
blob: {
|
||||
hasWallet: true,
|
||||
// A later schema that renamed the field and moved the key
|
||||
// material, written by a build this one knows nothing about, and
|
||||
// stamped with no version because this build never wrote one.
|
||||
wallets: [
|
||||
{
|
||||
id: "wallet-1",
|
||||
label: "Wallet 1",
|
||||
accounts: [{ addr: ADDRESS, wei: "0x0" }],
|
||||
keyring: { kind: "hd", vault: "…" },
|
||||
},
|
||||
],
|
||||
profileFormat: "am-2",
|
||||
activeAccount: ADDRESS,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ------------------------------------------------------------- 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;
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------- tests
|
||||
|
||||
describe("a stored profile the popup cannot read", () => {
|
||||
for (const { name, blob } of CORRUPT_BLOBS) {
|
||||
test(`${name}: the recovery screen, not a blank popup`, async () => {
|
||||
const env = await bootPopup(blob);
|
||||
|
||||
// Asserted together, and in the audit's own shape: a failure here
|
||||
// prints both what was on screen and what the console said, which
|
||||
// is the pair that identifies this defect.
|
||||
expect({
|
||||
visibleViews: env.visibleViews(),
|
||||
errors: env.pageErrors,
|
||||
}).toEqual({ visibleViews: ["state-recovery"], errors: [] });
|
||||
|
||||
// The screen has to NAME the problem. A blank recovery screen is
|
||||
// the same dead end with a border around it.
|
||||
expect(env.text("state-recovery-problem").length).toBeGreaterThan(
|
||||
10,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("the Settings gear is hidden, since every screen behind it reads the profile", async () => {
|
||||
const env = await bootPopup(CORRUPT_BLOBS[0].blob);
|
||||
|
||||
expect(env.hidden("btn-settings")).toBe(true);
|
||||
});
|
||||
|
||||
test("it does not write over the record it could not read", async () => {
|
||||
// The blob is evidence, and possibly the only copy of key material in
|
||||
// a shape a later build could recover. A boot that normalized it back
|
||||
// into storage would destroy exactly that.
|
||||
const env = await bootPopup(CORRUPT_BLOBS[2].blob);
|
||||
|
||||
expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[2].blob);
|
||||
expect(env.storage.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the export on the recovery screen", () => {
|
||||
test("hands back the raw stored record verbatim", async () => {
|
||||
const env = await bootPopup(CORRUPT_BLOBS[2].blob);
|
||||
|
||||
await env.click("btn-state-recovery-export");
|
||||
|
||||
// Shown in the page, which always works, whatever the browser does
|
||||
// with a download from an extension popup.
|
||||
expect(env.hidden("state-recovery-blob")).toBe(false);
|
||||
expect(JSON.parse(env.value("state-recovery-blob"))).toEqual(
|
||||
CORRUPT_BLOBS[2].blob,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the destructive reset on the recovery screen", () => {
|
||||
test("erases nothing without the typed confirmation", async () => {
|
||||
const env = await bootPopup(CORRUPT_BLOBS[0].blob);
|
||||
|
||||
env.node("state-recovery-reset-input").value = "yes";
|
||||
await env.click("btn-state-recovery-reset");
|
||||
|
||||
expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[0].blob);
|
||||
expect(env.text("state-recovery-flash").length).toBeGreaterThan(10);
|
||||
expect(env.reloaded()).toBe(0);
|
||||
});
|
||||
|
||||
test("erases the stored profile once the phrase is typed", async () => {
|
||||
const env = await bootPopup(CORRUPT_BLOBS[0].blob);
|
||||
|
||||
env.node("state-recovery-reset-input").value = "erase my wallet";
|
||||
await env.click("btn-state-recovery-reset");
|
||||
|
||||
expect(env.storage.read("autistmask")).toBeUndefined();
|
||||
expect(env.reloaded()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an unversioned profile that is perfectly valid", () => {
|
||||
// The upgrade case. Every install in the field is in this state, and the
|
||||
// popup must load it, not offer to wipe it.
|
||||
test("boots to the wallet list, not the recovery screen", async () => {
|
||||
const env = await bootPopup(unversionedValidProfile());
|
||||
|
||||
expect(env.visibleViews()).toEqual(["main"]);
|
||||
expect(env.pageErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test("is migrated in place: the version is stamped, the wallet survives", async () => {
|
||||
const env = await bootPopup(unversionedValidProfile());
|
||||
|
||||
const stored = env.storage.read("autistmask");
|
||||
expect(stored.schemaVersion).toBe(1);
|
||||
expect(stored.wallets).toHaveLength(1);
|
||||
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
|
||||
expect(stored.wallets[0].addresses[0].address).toBe(ADDRESS);
|
||||
expect(stored.activeAddress).toBe(ADDRESS);
|
||||
expect(stored.allowedSites).toEqual({ [ADDRESS]: ["dapp.example"] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("a first run with nothing in storage", () => {
|
||||
test("boots to Welcome", async () => {
|
||||
const env = await bootPopup(undefined);
|
||||
|
||||
expect(env.visibleViews()).toEqual(["welcome"]);
|
||||
expect(env.pageErrors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a garbage value in a field the gate does not check", () => {
|
||||
// The gate refuses only what nothing can floor: the wallet list, the
|
||||
// version, the network key. Everything else is normalizePersisted()'s job,
|
||||
// and where that job was written as `saved.x || default` rather than a
|
||||
// type check, a TRUTHY value of the wrong type walked straight through and
|
||||
// threw on the first dereference — the same blank popup this issue is
|
||||
// about, measured the same way. Every row below did, at the head named
|
||||
// against it; none has ever been removed from this list.
|
||||
//
|
||||
// These belong on the floor rather than in the gate: none of these values
|
||||
// carries key material, all have a sane default, and sending a user whose
|
||||
// wallets are perfectly readable to an export-or-erase screen over a
|
||||
// broken token list would destroy more than it saves.
|
||||
//
|
||||
// The CONTAINER and its ELEMENTS are separate defects. Round 2 floored the
|
||||
// containers with Array.isArray(), which left every row whose container is
|
||||
// a well-formed list of malformed entries still blanking the popup: the
|
||||
// dereference is `t.address.toLowerCase()`, one level below the check.
|
||||
const CORRUPT_FIELDS = [
|
||||
// Container shapes. Observed at 2e2ecf9, before the round-2 floor:
|
||||
// trackedTokens: "nope" -> views=[] "Cannot read properties of
|
||||
// undefined (reading 'toLowerCase')"
|
||||
// trackedTokens: 42 -> views=[] "trackedTokens is not iterable"
|
||||
// trackedTokens: {a:1} -> views=[] "trackedTokens is not iterable"
|
||||
// activeAddress: 42 -> views=[] "address.slice is not a
|
||||
// function"
|
||||
// activeAddress: {a:1} -> views=[] "address.slice is not a
|
||||
// function"
|
||||
{ name: "trackedTokens is a string", patch: { trackedTokens: "nope" } },
|
||||
{ name: "trackedTokens is a number", patch: { trackedTokens: 42 } },
|
||||
{
|
||||
name: "trackedTokens is an object",
|
||||
patch: { trackedTokens: { a: 1 } },
|
||||
},
|
||||
{ name: "activeAddress is a number", patch: { activeAddress: 42 } },
|
||||
{
|
||||
name: "activeAddress is an object",
|
||||
patch: { activeAddress: { a: 1 } },
|
||||
},
|
||||
// Element shapes: a list, holding entries that are not token records.
|
||||
// Observed at a10a984, AFTER the container floor:
|
||||
// [1,2] -> views=[] "Cannot read properties of undefined
|
||||
// (reading 'toLowerCase')"
|
||||
// [null] -> views=[] "Cannot read properties of null
|
||||
// (reading 'address')"
|
||||
// [{}] -> views=[] "Cannot read properties of undefined
|
||||
// (reading 'toLowerCase')"
|
||||
// [{address:42}] -> views=[] "t.address.toLowerCase is not a
|
||||
// function"
|
||||
// ["0xAA…"] -> views=[] "Cannot read properties of undefined
|
||||
// (reading 'toLowerCase')"
|
||||
{
|
||||
name: "trackedTokens holds numbers",
|
||||
patch: { trackedTokens: [1, 2] },
|
||||
},
|
||||
{
|
||||
name: "trackedTokens holds null",
|
||||
patch: { trackedTokens: [null] },
|
||||
},
|
||||
{
|
||||
name: "trackedTokens holds a record with no address",
|
||||
patch: { trackedTokens: [{}] },
|
||||
},
|
||||
{
|
||||
name: "trackedTokens holds a record whose address is a number",
|
||||
patch: { trackedTokens: [{ address: 42 }] },
|
||||
},
|
||||
{
|
||||
name: "trackedTokens holds bare address strings",
|
||||
patch: { trackedTokens: [TOKEN_ADDRESS] },
|
||||
},
|
||||
];
|
||||
|
||||
// The same defect one level deeper, inside a wallet the gate accepted.
|
||||
// tokenBalances is written WHOLESALE by refreshBalances(), so the partial
|
||||
// write https://git.eeqj.de/sneak/AutistMask/issues/311 names as the live
|
||||
// cause of a corrupt record lands exactly here. Observed at a10a984:
|
||||
// "x" -> views=[] "Cannot read properties of undefined (reading
|
||||
// 'toLowerCase')" (a string iterates as characters)
|
||||
// [null] -> views=[] "Cannot read properties of null (reading
|
||||
// 'balance')"
|
||||
// [42] -> views=[] "Cannot read properties of undefined (reading
|
||||
// 'toLowerCase')"
|
||||
// 42 -> views=[] "number 42 is not iterable"
|
||||
const CORRUPT_TOKEN_BALANCES = [
|
||||
{ name: "a string", value: "x" },
|
||||
{ name: "a list holding null", value: [null] },
|
||||
{ name: "a list of numbers", value: [42] },
|
||||
{ name: "a number", value: 42 },
|
||||
];
|
||||
|
||||
function profileWithTokenBalances(value) {
|
||||
const profile = unversionedValidProfile();
|
||||
profile.wallets[0].addresses[0].tokenBalances = value;
|
||||
return profile;
|
||||
}
|
||||
|
||||
for (const { name, patch } of CORRUPT_FIELDS) {
|
||||
test(`${name}: a working popup, not a blank one`, async () => {
|
||||
const env = await bootPopup(
|
||||
Object.assign(unversionedValidProfile(), patch),
|
||||
);
|
||||
|
||||
expect({
|
||||
visibleViews: env.visibleViews(),
|
||||
errors: env.pageErrors,
|
||||
}).toEqual({ visibleViews: ["main"], errors: [] });
|
||||
});
|
||||
}
|
||||
|
||||
for (const { name, value } of CORRUPT_TOKEN_BALANCES) {
|
||||
test(`an address whose tokenBalances is ${name}: a working popup, not a blank one`, async () => {
|
||||
const env = await bootPopup(profileWithTokenBalances(value));
|
||||
|
||||
expect({
|
||||
visibleViews: env.visibleViews(),
|
||||
errors: env.pageErrors,
|
||||
}).toEqual({ visibleViews: ["main"], errors: [] });
|
||||
});
|
||||
}
|
||||
|
||||
test("the wallet is intact afterwards, and the bad value is gone", async () => {
|
||||
const env = await bootPopup(
|
||||
Object.assign(unversionedValidProfile(), {
|
||||
trackedTokens: "nope",
|
||||
activeAddress: 42,
|
||||
}),
|
||||
);
|
||||
|
||||
const stored = env.storage.read("autistmask");
|
||||
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
|
||||
expect(stored.trackedTokens).toEqual([]);
|
||||
// Floored to null, then filled in by init()'s auto-default.
|
||||
expect(stored.activeAddress).toBe(ADDRESS);
|
||||
});
|
||||
|
||||
test("an empty activeAddress does not leave the popup with none selected", async () => {
|
||||
// "" is text, so a type check alone lets it through — and init()
|
||||
// auto-selects the first address only on a STRICT null, so it has to
|
||||
// be floored to null rather than kept.
|
||||
const env = await bootPopup(
|
||||
Object.assign(unversionedValidProfile(), { activeAddress: "" }),
|
||||
);
|
||||
|
||||
expect(env.visibleViews()).toEqual(["main"]);
|
||||
expect(env.storage.read("autistmask").activeAddress).toBe(ADDRESS);
|
||||
});
|
||||
|
||||
test("a malformed token entry is dropped, and the well-formed ones beside it survive", async () => {
|
||||
const env = await bootPopup(
|
||||
Object.assign(unversionedValidProfile(), {
|
||||
trackedTokens: [
|
||||
1,
|
||||
null,
|
||||
{},
|
||||
{ address: 42 },
|
||||
TOKEN_ADDRESS,
|
||||
{ address: TOKEN_ADDRESS, symbol: "AAA", decimals: 18 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const stored = env.storage.read("autistmask");
|
||||
expect(stored.trackedTokens).toEqual([
|
||||
{ address: TOKEN_ADDRESS, symbol: "AAA", decimals: 18 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("a malformed tokenBalances entry is dropped, and the wallet and its address survive", async () => {
|
||||
const env = await bootPopup(
|
||||
profileWithTokenBalances([
|
||||
null,
|
||||
42,
|
||||
{ address: TOKEN_ADDRESS, symbol: "AAA", balance: "2.0" },
|
||||
]),
|
||||
);
|
||||
|
||||
const stored = env.storage.read("autistmask");
|
||||
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
|
||||
expect(stored.wallets[0].addresses[0].address).toBe(ADDRESS);
|
||||
expect(stored.wallets[0].addresses[0].tokenBalances).toEqual([
|
||||
{ address: TOKEN_ADDRESS, symbol: "AAA", balance: "2.0" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user