// The lost-password route off the delete-wallet screen (issue #312). // // What is pinned here is that a user who has forgotten the password can // still get out — no password is asked for and none is checked — and that // the escape hatch destroys exactly the wallet it names and nothing else. // The second half is the dangerous one: this is the only control in the // product that erases key material without the password that encrypted it, // so an off-by-one in the wallet it removes would take a wallet whose // owner never asked for it to be touched. // // The assertions are made against what came back OUT of extension storage, // not against the live `state` object. Deleting a wallet in memory and // never persisting it looks identical from `state`, and a build that never // wrote at all would pass a check that only reads `state` back. // // That makes the storage stub load-bearing, so it is a real store that // structured-clones on both `set` and `get`. A stub whose `get` hands back // the same object its `set` was given aliases the caller's own array: the // test then reads its own in-memory mutation and calls it persistence, and // passes against a build that persists nothing (see issue #324). The // aliasing is closed off explicitly by the first test below rather than // left as an assumption about `structuredClone`. // // The view is driven against a minimal DOM stub, in the same shape as // tests/exportPrivkey.test.js: the module reads and writes named nodes and // needs nothing else from a document. const mockSettingsShow = jest.fn(); jest.mock("../src/popup/views/settings", () => ({ show: mockSettingsShow, })); jest.mock("../src/shared/vault", () => ({ decryptWithPassword: jest.fn(), })); const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews"); const VIEW = "delete-wallet-lost-password"; // Fixed addresses — never used for anything but these tests. const A0 = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; const A1 = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599"; const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; // ------------------------------------------------------------ DOM stub function makeElement(id) { const classes = new Set(); const el = { id, textContent: "", value: "", innerHTML: "", disabled: false, style: {}, dataset: {}, listeners: {}, 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); }, appendChild: () => {}, remove: () => {}, querySelectorAll: () => [], }; return el; } function makeDocument() { const els = new Map(); return { getElementById(id) { // The debug banner is created on demand by helpers.js; 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: () => makeElement("created"), addEventListener: () => {}, body: { prepend: () => {} }, }; } // --------------------------------------------------------- storage stub // A store that behaves the way `chrome.storage.local` does: what goes in is // serialized, so the caller keeps no handle on what came to rest there, and // what comes out is a fresh object the caller may mutate freely. function makeStorage() { let store = {}; return { get: async (keys) => { const wanted = keys === undefined || keys === null ? Object.keys(store) : [].concat(keys); const out = {}; for (const key of wanted) { if (key in store) out[key] = structuredClone(store[key]); } return out; }, set: async (items) => { for (const [key, value] of Object.entries(items)) { store[key] = structuredClone(value); } }, // Test-only: what the extension would find on a cold start. _raw: () => structuredClone(store), }; } // ------------------------------------------------------------ harness function wallet(name, secret, addresses) { return { type: "hd", name, xpub: "xpub-" + name, encryptedSecret: secret, nextIndex: addresses.length, addresses: addresses.map((address) => ({ address, balance: "0.0000", tokenBalances: [], })), }; } function load() { jest.resetModules(); mockSettingsShow.mockClear(); const storage = makeStorage(); const sent = []; globalThis.chrome = { storage: { local: storage }, runtime: { sendMessage: (msg) => sent.push(msg) }, }; globalThis.document = makeDocument(); const helpers = require("../src/popup/views/helpers"); const { state } = require("../src/shared/state"); const vault = require("../src/shared/vault"); const deleteWallet = require("../src/popup/views/deleteWallet"); state.hasWallet = true; state.wallets = [ wallet("Wallet 1", "secret-one", [A0, A1]), wallet("Wallet 2", "secret-two", [B0]), wallet("Wallet 3", "secret-three", [C0]), ]; state.selectedWallet = 0; state.selectedAddress = 0; state.activeAddress = A0; state.allowedSites = { [A0]: ["a.example"], [B0]: ["b.example"] }; state.deniedSites = { [B0]: ["c.example"], [C0]: ["d.example"] }; state.viewStack = ["main", "settings"]; state.currentView = "settings"; const renderWalletList = jest.fn(); deleteWallet.init({ renderWalletList }); return { helpers, state, vault, deleteWallet, storage, sent }; } function click(id) { const el = globalThis.document.getElementById(id); return Promise.all((el.listeners.click || []).map((fn) => fn())); } function node(id) { return globalThis.document.getElementById(id); } // The wallets as the extension would read them back on a cold start. async function persistedWallets(storage) { const result = await storage.get("autistmask"); return result.autistmask.wallets; } // Open the lost-password screen for a wallet, the way the user does. async function openLostPassword(deleteWallet, walletIdx) { deleteWallet.show(walletIdx); await click("btn-delete-wallet-lost-password"); } // ------------------------------------------------------------ tests // The stub is what every persistence assertion below rests on, so its one // dangerous failure mode is closed off first. An aliasing store passes // every other test in this file against a build that never writes. describe("the storage stub", () => { test("does not hand back the object it was given", async () => { const storage = makeStorage(); const written = { wallets: [{ name: "Wallet 1" }] }; await storage.set({ autistmask: written }); written.wallets.push({ name: "Wallet 2" }); written.wallets[0].name = "renamed after the write"; const readBack = (await storage.get("autistmask")).autistmask; expect(readBack.wallets).toHaveLength(1); expect(readBack.wallets[0].name).toBe("Wallet 1"); // And the other direction: mutating what came out must not reach // back into the store. readBack.wallets[0].name = "renamed after the read"; const again = (await storage.get("autistmask")).autistmask; expect(again.wallets[0].name).toBe("Wallet 1"); }); }); describe("reaching the screen", () => { test("the delete screen offers the route", async () => { const { deleteWallet, state } = load(); await openLostPassword(deleteWallet, 1); expect(state.currentView).toBe(VIEW); expect(node("delete-wallet-lost-name").textContent).toBe("Wallet 2"); expect(node("delete-wallet-lost-name-echo").textContent).toBe( "Wallet 2", ); }); // Both delete screens hang off Settings. Pushing one onto the other // would leave Back on the confirm screen popping onto itself. test("it does not push the screen it came from", async () => { const { deleteWallet, state } = load(); await openLostPassword(deleteWallet, 1); expect(state.viewStack).toEqual(["main", "settings"]); }); test("Back returns to the delete screen with its wallet still chosen", async () => { const { deleteWallet, state } = load(); await openLostPassword(deleteWallet, 1); await click("btn-delete-wallet-lost-back"); expect(state.currentView).toBe("delete-wallet-confirm"); expect(node("delete-wallet-name").textContent).toBe("Wallet 2"); expect(state.viewStack).toEqual(["main", "settings"]); // The confirm screen is usable, not merely on screen: the wallet // it holds is the one that was chosen, so its own button does not // answer "No wallet selected for deletion." node("delete-wallet-password").value = "some password"; const { decryptWithPassword } = require("../src/shared/vault"); decryptWithPassword.mockRejectedValue(new Error("nope")); await click("btn-delete-wallet-confirm"); expect(node("delete-wallet-flash").textContent).toBe( "That password is incorrect. Please try again.", ); }); }); describe("the typed confirmation", () => { test("a name that is not the wallet's deletes nothing", async () => { const { deleteWallet, state, storage } = load(); await openLostPassword(deleteWallet, 1); node("delete-wallet-lost-name-input").value = "Wallet 3"; await click("btn-delete-wallet-lost-confirm"); expect(node("delete-wallet-lost-flash").textContent).toBe( "That is not the name of this wallet. Type Wallet 2 to confirm.", ); expect(node("delete-wallet-lost-flash").style.visibility).toBe( "visible", ); expect(state.wallets.map((w) => w.name)).toEqual([ "Wallet 1", "Wallet 2", "Wallet 3", ]); expect(state.currentView).toBe(VIEW); // Nothing was destroyed on disk either. Storage is not empty — // showView() persists the current screen on the way in — so what // is asserted is that all three wallets are still in it. const persisted = await persistedWallets(storage); expect(persisted.map((w) => w.encryptedSecret)).toEqual([ "secret-one", "secret-two", "secret-three", ]); }); test("an empty field deletes nothing", async () => { const { deleteWallet, state } = load(); await openLostPassword(deleteWallet, 1); await click("btn-delete-wallet-lost-confirm"); expect(node("delete-wallet-lost-flash").style.visibility).toBe( "visible", ); expect(state.wallets).toHaveLength(3); }); // Not a secret and not a password: it asks whether the user knows // which wallet they are on. Refusing the name they can plainly read, // over letter case, would only teach them to distrust the control. test("case and surrounding spaces do not matter", async () => { const { deleteWallet, state, storage } = load(); await openLostPassword(deleteWallet, 1); node("delete-wallet-lost-name-input").value = " wALLet 2 "; await click("btn-delete-wallet-lost-confirm"); expect(state.wallets.map((w) => w.name)).toEqual([ "Wallet 1", "Wallet 3", ]); expect(await persistedWallets(storage)).toHaveLength(2); }); // A name with a doubled inner space RENDERS with one — HTML collapses // runs of whitespace — so the string the user can see and type is not // the string the name is stored as. Comparing the two raw would make // this wallet's confirmation impossible to satisfy by any typing at // all, wedging the one screen that exists to unwedge people. test("a doubled space inside the name is typed back as one", async () => { const { deleteWallet, state, storage } = load(); state.wallets[1].name = "My Wallet"; await openLostPassword(deleteWallet, 1); // What the DOM was handed still has both spaces; what the user // reads off the screen, and therefore types, has one. expect(node("delete-wallet-lost-name").textContent).toBe("My Wallet"); node("delete-wallet-lost-name-input").value = "My Wallet"; await click("btn-delete-wallet-lost-confirm"); expect(state.wallets.map((w) => w.name)).toEqual([ "Wallet 1", "Wallet 3", ]); const persisted = await persistedWallets(storage); expect(persisted.map((w) => w.encryptedSecret)).toEqual([ "secret-one", "secret-three", ]); }); }); describe("deleting without the password", () => { test("no password is asked for and none is checked", async () => { const { deleteWallet, vault, storage } = load(); await openLostPassword(deleteWallet, 1); node("delete-wallet-lost-name-input").value = "Wallet 2"; await click("btn-delete-wallet-lost-confirm"); expect(vault.decryptWithPassword).not.toHaveBeenCalled(); expect(await persistedWallets(storage)).toHaveLength(2); }); // The load-bearing assertion of the whole file, and the one that says // this control is safe to give a user who cannot prove anything: it // removes the wallet it named, and every other wallet survives intact, // key material included. test("exactly the named wallet is destroyed", async () => { const { deleteWallet, storage } = load(); await openLostPassword(deleteWallet, 1); node("delete-wallet-lost-name-input").value = "Wallet 2"; await click("btn-delete-wallet-lost-confirm"); const wallets = await persistedWallets(storage); expect(wallets.map((w) => w.name)).toEqual(["Wallet 1", "Wallet 3"]); expect(wallets.map((w) => w.encryptedSecret)).toEqual([ "secret-one", "secret-three", ]); expect(wallets.map((w) => w.xpub)).toEqual([ "xpub-Wallet 1", "xpub-Wallet 3", ]); expect(wallets[0].addresses.map((a) => a.address)).toEqual([A0, A1]); expect(wallets[1].addresses.map((a) => a.address)).toEqual([C0]); // The deleted wallet's secret is gone from storage entirely, not // merely unreferenced by the wallet list. expect(JSON.stringify(storage._raw())).not.toContain("secret-two"); expect(JSON.stringify(storage._raw())).not.toContain("xpub-Wallet 2"); }); test("only the deleted wallet's site permissions are dropped", async () => { const { deleteWallet, storage } = load(); await openLostPassword(deleteWallet, 1); node("delete-wallet-lost-name-input").value = "Wallet 2"; await click("btn-delete-wallet-lost-confirm"); const saved = (await storage.get("autistmask")).autistmask; expect(saved.allowedSites).toEqual({ [A0]: ["a.example"] }); expect(saved.deniedSites).toEqual({ [C0]: ["d.example"] }); }); // The route shares finishDelete() with the password route, so the // selection repair and the accountsChanged broadcast are the same on // both. Deleting a wallet that did not own the active address must // leave that address, and the selection, exactly where they were. test("a selection in another wallet is left alone", async () => { const { deleteWallet, storage, sent } = load(); await openLostPassword(deleteWallet, 1); node("delete-wallet-lost-name-input").value = "Wallet 2"; await click("btn-delete-wallet-lost-confirm"); const saved = (await storage.get("autistmask")).autistmask; expect(saved.activeAddress).toBe(A0); expect(saved.selectedWallet).toBe(0); expect(saved.selectedAddress).toBe(0); expect(sent).toEqual([]); // Settings is stubbed, so this is where the route hands over, not // where it renders. expect(mockSettingsShow).toHaveBeenCalled(); }); test("deleting the wallet holding the active address moves it and says so", async () => { const { deleteWallet, storage, sent } = load(); await openLostPassword(deleteWallet, 0); node("delete-wallet-lost-name-input").value = "Wallet 1"; await click("btn-delete-wallet-lost-confirm"); const saved = (await storage.get("autistmask")).autistmask; expect(saved.wallets.map((w) => w.name)).toEqual([ "Wallet 2", "Wallet 3", ]); expect(saved.activeAddress).toBe(B0); expect(sent).toEqual([{ type: "AUTISTMASK_ACTIVE_CHANGED" }]); }); test("deleting the last wallet lands on Welcome with nothing left", async () => { const { deleteWallet, state, storage } = load(); state.wallets = [wallet("Wallet 1", "secret-one", [A0])]; state.allowedSites = { [A0]: ["a.example"] }; state.deniedSites = {}; await openLostPassword(deleteWallet, 0); node("delete-wallet-lost-name-input").value = "Wallet 1"; await click("btn-delete-wallet-lost-confirm"); const saved = (await storage.get("autistmask")).autistmask; expect(saved.wallets).toEqual([]); expect(saved.hasWallet).toBe(false); expect(saved.activeAddress).toBeNull(); expect(saved.allowedSites).toEqual({}); expect(state.currentView).toBe("welcome"); expect(JSON.stringify(storage._raw())).not.toContain("secret-one"); }); }); describe("what the screen leaves behind", () => { test("the typed confirmation is wiped when the screen is left", async () => { const { helpers, deleteWallet } = load(); await openLostPassword(deleteWallet, 1); node("delete-wallet-lost-name-input").value = "Wallet 2"; // The Settings gear, which is not this screen's Back button. helpers.showView("settings"); expect(node("delete-wallet-lost-name-input").value).toBe(""); expect(node("delete-wallet-lost-flash").textContent).toBe(""); expect(node("delete-wallet-lost-flash").style.visibility).toBe( "hidden", ); }); // Left mid-delete, the screen has to come back usable. test("the confirm button is re-enabled on the way out", async () => { const { helpers, deleteWallet } = load(); await openLostPassword(deleteWallet, 1); node("btn-delete-wallet-lost-confirm").disabled = true; helpers.showView("settings"); expect(node("btn-delete-wallet-lost-confirm").disabled).toBe(false); }); // A wallet name is not a secret, so the screen is excluded for the // other reason: reopening the popup must not land the user on a screen // whose button erases key material. test("the popup may not reopen onto it", () => { expect(RESTORABLE_VIEWS.has(VIEW)).toBe(false); expect(RESTORABLE_VIEWS.has("delete-wallet-confirm")).toBe(false); }); });