// 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"; // ------------------------------------------------------------- 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([]); }); });