// Tests for the recovery phrase display (issue #161). // // These cover which wallet types may be offered the action at all, the // exclusion of the screen from the set of views the popup may reopen onto, // the absence of any path from this module to the logger, and, against a // minimal DOM stub, where Back goes after the screen is left by the settings // gear or by its own Back. The rest of the DOM behaviour it guards — nothing rendered before the // password is accepted, a wrong password revealing nothing, and the wipe on // leaving — is driven against the real popup in a real browser by // tests/e2e/run.js, which is where every other view behaviour is tested. jest.mock("../src/shared/vault", () => ({ decryptWithPassword: jest.fn(), })); const fs = require("fs"); const path = require("path"); const { walletHasRecoveryPhrase } = require("../src/shared/wallet"); const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews"); const SHOW_PHRASE_VIEW = "show-phrase"; // helpers.js pulls in state.js, which reads chrome.storage.local at load. function loadHelpers() { globalThis.chrome = { storage: { local: { get: async () => ({}), set: async () => {} } }, }; return require("../src/popup/views/helpers"); } describe("which wallets have a recovery phrase", () => { test("an HD wallet does", () => { expect(walletHasRecoveryPhrase({ type: "hd" })).toBe(true); }); // A key wallet holds a bare private key and an xprv wallet an extended // private key. Neither can be turned back into words, so neither may be // offered the action. test("a key wallet does not", () => { expect(walletHasRecoveryPhrase({ type: "key" })).toBe(false); }); test("an xprv wallet does not", () => { expect(walletHasRecoveryPhrase({ type: "xprv" })).toBe(false); }); test("an unknown or missing wallet type does not", () => { expect(walletHasRecoveryPhrase({ type: "something-new" })).toBe(false); expect(walletHasRecoveryPhrase({})).toBe(false); expect(walletHasRecoveryPhrase(undefined)).toBe(false); }); }); describe("views the popup may reopen onto", () => { // Restoring onto a secret screen would put the phrase on screen with no // password prompt in front of it, on a popup the user may have reopened // by accident. test("the recovery phrase screen is not restorable", () => { expect(RESTORABLE_VIEWS.has(SHOW_PHRASE_VIEW)).toBe(false); }); test("the private key export screen is not restorable either", () => { expect(RESTORABLE_VIEWS.has("export-privkey")).toBe(false); }); test("the recovery phrase screen is still a registered view", () => { const { VIEWS } = loadHelpers(); expect(VIEWS).toContain(SHOW_PHRASE_VIEW); }); // Guards the other direction: a restorable name that is not a real view // would leave restoreView() showing nothing at all. test("every restorable view is a registered view", () => { const { VIEWS } = loadHelpers(); for (const view of RESTORABLE_VIEWS) { expect(VIEWS).toContain(view); } }); }); // Just enough document for helpers.showView() and this view: every element // is made on first lookup and keeps what the view writes to it, its click // handler included. function makeDocument() { const els = new Map(); function makeElement() { const classes = new Set(); const el = { textContent: "", value: "", style: {}, classList: { add: (name) => classes.add(name), remove: (name) => classes.delete(name), contains: (name) => classes.has(name), toggle: (name, on) => on ? classes.add(name) : classes.delete(name), }, addEventListener: (name, fn) => { if (name === "click") el.onClick = fn; }, }; return el; } return { getElementById(id) { // Created on demand by helpers.js; absent on a mainnet popup // that is not a debug build. if (id === "debug-banner") return null; if (!els.has(id)) els.set(id, makeElement()); return els.get(id); }, }; } describe("Back from Settings after leaving by the settings gear", () => { // On Settings, opened from Home. function load() { jest.resetModules(); globalThis.document = makeDocument(); const helpers = loadHelpers(); const { state } = require("../src/shared/state"); const showPhrase = require("../src/popup/views/showPhrase"); showPhrase.init(); state.wallets = [{ name: "Wallet 1", type: "hd", addresses: [] }]; state.currentView = "settings"; state.viewStack = ["main"]; return { helpers, state, showPhrase }; } // https://git.eeqj.de/sneak/AutistMask/issues/461: leaving drops the // wallet selection, so Back onto this screen showed a password prompt // that could only answer "No wallet is selected." test("does not land on the recovery phrase screen", () => { const { helpers, state, showPhrase } = load(); // Opened from the wallet list in Settings, then left by the gear: // push the current view, then show Settings. showPhrase.show(0); helpers.pushCurrentView(); helpers.showView("settings"); expect(state.viewStack).toEqual(["main", "settings"]); helpers.goBack(); expect(state.currentView).not.toBe(SHOW_PHRASE_VIEW); }); // This Back takes Settings off the stack before the screen is left, so // the stack's top is then the entry Back from Settings will need. test("its own Back button leaves the rest of the stack alone", () => { const { state, showPhrase } = load(); showPhrase.show(0); globalThis.document.getElementById("btn-show-phrase-back").onClick(); expect(state.currentView).toBe("settings"); expect(state.viewStack).toEqual(["main"]); }); }); describe("the phrase cannot reach the logger", () => { const source = fs.readFileSync( path.join(__dirname, "..", "src", "popup", "views", "showPhrase.js"), "utf8", ); // The decrypted phrase only ever lives in a local and in the DOM node // that displays it. The module has no logger to hand it to, and this // pins that: src/shared/log.js writes to the console, and a console // record of a recovery phrase outlives the popup. test("the view does not import src/shared/log.js", () => { expect(source).not.toMatch(/require\(["'][^"']*shared\/log["']\)/); }); test("the view calls no logger method", () => { expect(source).not.toMatch(/\blog\.(debugf|infof|warnf|errorf)\b/); }); });