All checks were successful
check / check (push) Successful in 42s
The send confirmation and the delete-wallet confirmation rendered
"Wrong password." — a fragment, which README Language & Labeling and
RULES.md:120 both forbid — while the two reveal screens said "That
password is not correct." and the two dApp approval paths said "That
password is incorrect." Three wordings for one condition, on screens a
user can reach minutes apart.
All six decryptWithPassword call sites, across the five views that own
one, now show the wording the approval paths introduced:
That password is incorrect. Please try again.
Strings only. Nothing about how a wrong password is handled changes: it
still fails closed on every screen, and the approval paths' settlement,
claim/release interlock and retry behaviour are untouched.
The new test scans the source for the call sites rather than driving
each view, because the invariant is about the set: a seventh call site
has to join it, and a per-view test cannot notice a screen nobody wrote
one for. It asserts per call site, not per file — each decrypt is read
back to its own catch handler and the prose that handler shows must be
the canonical sentence and nothing else. approval.js decrypts twice and
is where the divergence came from, so a per-file check that only asks
whether the sentence appears somewhere in the file passes while one of
those two says something novel. Exact equality catches a new wording,
not only a known-superseded one.
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/);
|
|
});
|
|
});
|