// Tests for the private key export screen (issue #221). // // The screen holds the one secret that owns an address outright, so what is // pinned here is disposal: the key is wiped from the DOM whenever the screen // is left by any route, and a decrypt still in flight when the screen is // left never writes at all. That last case is the one a per-button wipe and // a naive leave hook both miss — the write lands after the wipe, with // nothing scheduled to wipe it again. // // The view is driven against a minimal DOM stub rather than a real browser: // the module is deliberately shaped like src/popup/views/showPhrase.js, with // no dependency that needs a document beyond the nodes it reads and writes. const mockPrivateKey = "0x" + "ab".repeat(32); jest.mock("ethereum-blockies-base64", () => () => "data:image/png;base64,x"); jest.mock("../src/shared/vault", () => ({ decryptWithPassword: jest.fn(), })); jest.mock("../src/shared/wallet", () => ({ getSignerForAddress: jest.fn(() => ({ privateKey: mockPrivateKey })), })); const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews"); const VIEW = "export-privkey"; const PASSWORD = "correct horse battery"; // ------------------------------------------------------------ DOM stub function makeElement(id, withParent) { 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: () => [], }; el.parentElement = withParent ? makeElement(id + "-parent", false) : null; 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, true)); return els.get(id); }, createElement: () => makeElement("created", false), addEventListener: () => {}, body: { prepend: () => {} }, }; } // ------------------------------------------------------------ harness function load() { jest.resetModules(); globalThis.chrome = { storage: { local: { get: async () => ({}), set: async () => {} } }, }; globalThis.document = makeDocument(); const helpers = require("../src/popup/views/helpers"); const { state } = require("../src/shared/state"); const vault = require("../src/shared/vault"); const wallet = require("../src/shared/wallet"); const exportPrivkey = require("../src/popup/views/exportPrivkey"); state.wallets = [ { name: "Wallet 1", type: "key", encryptedSecret: "ciphertext", addresses: [ { address: "0x" + "11".repeat(20), balance: "0.0000", tokenBalances: [], }, { address: "0x" + "22".repeat(20), balance: "0.0000", tokenBalances: [], }, ], }, ]; state.viewStack = []; state.currentView = "address"; exportPrivkey.init(); return { helpers, state, vault, wallet, exportPrivkey }; } 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); } // Start a reveal and hand back both the promise it returns and the resolver // for the decrypt it is waiting on, so a test can navigate away mid-flight. function startReveal(vault) { let resolveDecrypt; let rejectDecrypt; vault.decryptWithPassword.mockImplementation( () => new Promise((resolve, reject) => { resolveDecrypt = resolve; rejectDecrypt = reject; }), ); node("export-privkey-password").value = PASSWORD; const pending = click("btn-export-privkey-confirm"); return { pending, resolve: (v) => resolveDecrypt(v), reject: (e) => rejectDecrypt(e), }; } // ------------------------------------------------------------ tests describe("a decrypt still running when the screen is left", () => { // The load-bearing case. Without the liveness guard in reveal(), the // write lands after the leave hook has already wiped, and the key sits // in the hidden view for the life of the popup. test("never writes the key into the DOM", async () => { const { helpers, vault, wallet, exportPrivkey } = load(); exportPrivkey.show(0, 0); const reveal = startReveal(vault); // The settings gear, mid-decrypt. helpers.showView("settings"); reveal.resolve("wallet secret"); await reveal.pending; expect(node("export-privkey-value").textContent).toBe(""); // Nothing was even derived: the guard sits in front of the // derivation, not just in front of the write. expect(wallet.getSignerForAddress).not.toHaveBeenCalled(); }); // The generation counter, not merely the current-view check: by the time // the stale decrypt resolves the user is back on the screen, so a guard // that only asked "is this view showing?" would let the write through. test("never writes it after the screen is re-entered", async () => { const { helpers, vault, exportPrivkey } = load(); exportPrivkey.show(0, 0); const stale = startReveal(vault); helpers.showView("settings"); exportPrivkey.show(0, 1); expect(node("export-privkey-value").textContent).toBe(""); stale.resolve("wallet secret"); await stale.pending; expect(node("export-privkey-value").textContent).toBe(""); expect(node("export-privkey-result").classList.contains("hidden")).toBe( true, ); }); // Same hole on the failure path: a wrong-password error written after // the wipe would restore the flash line on a screen the user has left. test("never writes the failure message either", async () => { const { helpers, vault, exportPrivkey } = load(); exportPrivkey.show(0, 0); const reveal = startReveal(vault); helpers.showView("settings"); reveal.reject(new Error("decryption failed")); await reveal.pending; expect(node("export-privkey-flash").textContent).toBe(""); expect(node("export-privkey-flash").style.visibility).toBe("hidden"); }); }); describe("a reveal that is not interrupted", () => { // Guards the guard: a liveness check that rejected every write would // pass every test above and ship a screen that reveals nothing. test("puts the key on screen", async () => { const { vault, exportPrivkey } = load(); exportPrivkey.show(0, 0); const reveal = startReveal(vault); reveal.resolve("wallet secret"); await reveal.pending; expect(node("export-privkey-value").textContent).toBe(mockPrivateKey); expect(node("export-privkey-result").classList.contains("hidden")).toBe( false, ); // The password is dropped as soon as it has been spent. expect(node("export-privkey-password").value).toBe(""); }); test("writes nothing before the password is accepted", async () => { const { vault, exportPrivkey } = load(); exportPrivkey.show(0, 0); const reveal = startReveal(vault); expect(node("export-privkey-value").textContent).toBe(""); reveal.resolve("wallet secret"); await reveal.pending; }); test("reveals nothing when the password is wrong", async () => { const { vault, exportPrivkey } = load(); exportPrivkey.show(0, 0); const reveal = startReveal(vault); reveal.reject(new Error("decryption failed")); await reveal.pending; expect(node("export-privkey-value").textContent).toBe(""); expect(node("export-privkey-flash").textContent).toBe( "That password is not correct. Please try again.", ); }); }); describe("leaving the screen after the key is on it", () => { async function revealed() { const loaded = load(); loaded.exportPrivkey.show(0, 0); const reveal = startReveal(loaded.vault); reveal.resolve("wallet secret"); await reveal.pending; expect(node("export-privkey-value").textContent).toBe(mockPrivateKey); return loaded; } test("the Back button clears the key", async () => { await revealed(); await click("btn-export-privkey-back"); expect(node("export-privkey-value").textContent).toBe(""); expect(node("export-privkey-password").value).toBe(""); }); test("the settings gear clears the key", async () => { const { helpers } = await revealed(); helpers.showView("settings"); expect(node("export-privkey-value").textContent).toBe(""); expect(node("export-privkey-password").value).toBe(""); // And the screen is back to its password prompt, not to a result // panel that would flash an empty well on the next visit. expect(node("export-privkey-result").classList.contains("hidden")).toBe( true, ); expect( node("export-privkey-password-section").classList.contains( "hidden", ), ).toBe(false); }); // Any other navigation: the same hook covers routes that do not exist // yet, which is the point of registering it on the view rather than on // the controls that leave it. test("any other navigation clears the key", async () => { const { helpers } = await revealed(); helpers.showView("main"); expect(node("export-privkey-value").textContent).toBe(""); }); }); describe("views the popup may reopen onto", () => { // Restoring onto this screen would put a private key on display with no // password prompt in front of it, on a popup reopened by accident. test("the private key export screen is not restorable", () => { expect(RESTORABLE_VIEWS.has(VIEW)).toBe(false); }); test("it is still a registered view", () => { const { helpers } = load(); expect(helpers.VIEWS).toContain(VIEW); }); }); describe("the key cannot reach the logger", () => { const fs = require("fs"); const path = require("path"); const source = fs.readFileSync( path.join(__dirname, "..", "src", "popup", "views", "exportPrivkey.js"), "utf8", ); 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/); }); });