Some checks failed
check / check (push) Has been cancelled
A rejected password was reported three different ways depending on which screen you were on, including the fragment "Wrong password." which is not a sentence. All six decryptWithPassword call sites now show the same full sentence. Strings only -- a wrong password still fails closed on every screen and still resolves no pending approval. A test pins the invariant per call site: each decryptWithPassword call is walked out to its enclosing try and forward to that block's catch, and the prose shown there must equal the canonical sentence. Per-file matching was not enough, since a file with two call sites kept passing while one of them diverged.
332 lines
12 KiB
JavaScript
332 lines
12 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/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 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("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/);
|
|
});
|
|
});
|