Files
AutistMask/tests/exportPrivkey.test.js
T
sneak 64a9850d97
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
fix: the private key export screen opens again in the same session (closes #460)
show() found the address line through the element inside it, then
replaced the line's contents with renderAddressHtml(), which deleted
that element, so the next show() in the same popup session threw
before it navigated. The line now carries the export-privkey-address
id itself and is looked up by it. No other view that renders an
address finds its container through a child.

The jest DOM stub now takes an element out of the document when its
parent's contents are replaced, and a new test opens the screen
twice. The #253 e2e case no longer reopens the popup before its
second open.

Model: opus-5-5
2026-10-06 14:05:13 +00:00

368 lines
13 KiB
JavaScript

// 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/shared/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 = null;
if (withParent) {
// As in a browser, replacing the parent's contents takes this
// element out of the document: getElementById no longer finds it.
el.parentElement = makeElement(id + "-parent", false);
let html = "";
Object.defineProperty(el.parentElement, "innerHTML", {
get: () => html,
set: (value) => {
html = value;
el.removed = true;
},
});
}
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));
const el = els.get(id);
return el.removed ? null : el;
},
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 incorrect. 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("opening the screen again in the same popup session", () => {
// https://git.eeqj.de/sneak/AutistMask/issues/460: show() found the
// address line through an element inside it, which its own rendering
// deleted, so the second open threw before it navigated.
test("shows it for the address chosen the second time", async () => {
const { state, exportPrivkey } = load();
exportPrivkey.show(0, 0);
await click("btn-export-privkey-back");
expect(state.currentView).toBe("address");
exportPrivkey.show(0, 1);
expect(state.currentView).toBe(VIEW);
expect(node("export-privkey-title").textContent).toBe(
"Wallet 1 — Address 2",
);
expect(node("export-privkey-address").innerHTML).toContain(
"0x" + "22".repeat(20),
);
});
});
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/);
});
});