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.
631 lines
24 KiB
JavaScript
631 lines
24 KiB
JavaScript
// 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" },
|
|
]);
|
|
});
|
|
});
|