test: cover every control that refuses a defective wallet (closes #254)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

Each control that leads to a signature or to the private key now has a
test that it refuses a defective wallet before decrypting anything: Send
on the main, address and token screens, Export Private Key, and both
approval screens, as drawn and as clicked.

Send on the confirmation screen had no such check. The Send buttons
stand in front of it, but the popup reopens onto it from a saved view,
so it now refuses the same way.

The comments that said the wallet's key cannot be derived now say that
getSignerForAddress refuses it, and the walletDefects module comment
names both earlier import paths.

Model: opus-5-5
This commit was merged in pull request #464.
This commit is contained in:
2026-10-05 12:59:15 +02:00
parent d0bbb3d9eb
commit 5d26283cd0
6 changed files with 335 additions and 16 deletions
+294 -2
View File
@@ -4,8 +4,16 @@
// already in storage: the import that created it ran before the refusal
// existed. Such a wallet used to sign for the wrong tree and now throws on the
// send screen instead. These tests pin down that it is named and explained in
// the wallet list, that nothing on the way there throws, and that a wallet
// imported from a real master key is untouched by any of it.
// the wallet list, that every control leading to a signature or to the private
// key refuses it before asking for a password, that nothing on the way there
// throws, and that a wallet imported from a real master key is untouched by
// any of it.
// Mocked so that no password has to be hashed: the controls below are checked
// for whether they decrypt at all.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
}));
const { HDNodeWallet, Mnemonic } = require("ethers");
@@ -237,6 +245,290 @@ describe("the wallet list", () => {
});
});
// A minimal DOM for driving the popup views: any element exists on first
// lookup, and click() runs the listeners a view attached to it.
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
title: "",
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);
},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
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));
return els.get(id);
},
createElement: () => makeElement("created"),
addEventListener: () => {},
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
function click(id) {
return Promise.all((node(id).listeners.click || []).map((fn) => fn()));
}
// getSignerForAddress refuses this wallet's key, but only once the password
// has been typed and spent, and the screens report that refusal as a wrong
// password or a failed send. So every control that leads to it refuses first.
describe("every way to a signature or the private key refuses a defective wallet first", () => {
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
let state;
let decryptWithPassword;
let home;
let addressDetail;
let addressToken;
let approval;
let confirmTx;
let address;
let shortMessage;
// What the background answers when the approval window asks which
// approval it was opened for, and every message the popup sent it.
let approvalDetails;
let sent;
beforeAll(() => {
state = require("../src/shared/state").state;
decryptWithPassword =
require("../src/shared/vault").decryptWithPassword;
home = require("../src/popup/views/home");
addressDetail = require("../src/popup/views/addressDetail");
addressToken = require("../src/popup/views/addressToken");
approval = require("../src/popup/views/approval");
confirmTx = require("../src/popup/views/confirmTx");
});
beforeEach(() => {
const broken = brokenXprvWallet();
// A balance, so that no Send button's zero-balance refusal can stand
// in for the defect check.
broken.addresses[0].balance = "1.0000";
broken.addresses[0].tokenBalances = [];
address = broken.addresses[0].address;
shortMessage = walletDefect(broken).shortMessage;
approvalDetails = null;
sent = [];
globalThis.document = makeDocument();
globalThis.window = { close: () => {} };
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
runtime: {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
sent.push(msg);
if (!reply) return;
reply(
msg.type === "AUTISTMASK_GET_APPROVAL"
? approvalDetails
: null,
);
},
},
};
// What the wallet's stored secret decrypts to: the account-level key
// it was imported from.
decryptWithPassword.mockReset();
decryptWithPassword.mockResolvedValue(accountXprv(VECTOR_PHRASE));
state.wallets = [broken];
state.activeAddress = address;
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
state.viewStack = [];
});
afterEach(() => {
state.wallets = [];
state.activeAddress = null;
state.selectedWallet = null;
state.selectedAddress = null;
state.selectedToken = null;
});
test("Send on the main screen", async () => {
state.currentView = "main";
home.init({});
await click("btn-main-send");
expect(node("flash-msg").textContent).toBe(shortMessage);
expect(state.currentView).toBe("main");
});
test("Send on the address screen", async () => {
state.currentView = "address";
addressDetail.init({});
await click("btn-send");
expect(node("flash-msg").textContent).toBe(shortMessage);
expect(state.currentView).toBe("address");
});
test("Export Private Key on the address screen", async () => {
state.currentView = "address";
addressDetail.init({});
await click("btn-export-privkey");
expect(node("flash-msg").textContent).toBe(shortMessage);
expect(state.currentView).toBe("address");
});
test("Send on a token's screen", async () => {
state.currentView = "address-token";
addressToken.init({});
await click("btn-address-token-send");
expect(node("flash-msg").textContent).toBe(shortMessage);
expect(state.currentView).toBe("address-token");
});
// The popup reopens onto this screen from a saved view, so the Send
// buttons above are not the only way onto it. The screen is not drawn,
// because drawing it starts a fee estimate against the network; with a
// decrypt that fails, a handler without the check stops at the password
// instead of going on to a transaction that was never set up.
test("Send on the confirmation screen", async () => {
decryptWithPassword.mockRejectedValue(new Error("wrong password"));
state.currentView = "confirm-tx";
confirmTx.init({});
node("confirm-tx-password").value = "any password";
await click("btn-confirm-send");
expect(decryptWithPassword).not.toHaveBeenCalled();
expect(node("confirm-tx-password-error").textContent).toBe(
shortMessage,
);
});
async function openTxApproval() {
approvalDetails = {
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: address,
approvedTx: {
from: address,
to: RECIPIENT,
value: "0x0",
data: "0x",
chainId: 1,
nonce: 0,
gasLimit: "21000",
maxFeePerGas: "1000000000",
},
};
approval.init({});
await approval.show(1);
}
async function openSignApproval() {
approvalDetails = {
type: "sign",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: address,
// "Hello", as the hex a page sends.
signParams: {
method: "personal_sign",
message: "0x48656c6c6f",
from: address,
},
};
approval.init({});
await approval.show(1);
}
test("the transaction approval screen says so and disables Approve", async () => {
await openTxApproval();
expect(node("approve-tx-error").textContent).toBe(shortMessage);
expect(node("btn-approve-tx").disabled).toBe(true);
});
// The stub runs a disabled button's listener, which a browser would not:
// what is asked here is whether the handler refuses on its own.
test("Approve on the transaction approval screen does not decrypt", async () => {
await openTxApproval();
node("approve-tx-password").value = "any password";
await click("btn-approve-tx");
expect(decryptWithPassword).not.toHaveBeenCalled();
expect(sent.map((msg) => msg.type)).not.toContain(
"AUTISTMASK_TX_RESPONSE",
);
expect(node("approve-tx-error").textContent).toBe(shortMessage);
});
test("the signature approval screen says so and disables Approve", async () => {
await openSignApproval();
expect(node("approve-sign-error").textContent).toBe(shortMessage);
expect(node("btn-approve-sign").disabled).toBe(true);
});
test("Approve on the signature approval screen does not decrypt", async () => {
await openSignApproval();
node("approve-sign-password").value = "any password";
await click("btn-approve-sign");
expect(decryptWithPassword).not.toHaveBeenCalled();
expect(sent.map((msg) => msg.type)).not.toContain(
"AUTISTMASK_SIGN_RESPONSE",
);
expect(node("approve-sign-error").textContent).toBe(shortMessage);
});
});
describe("no path throws an unhandled error for a defective wallet", () => {
test("address derivation from the stored xpub still works", () => {
// The stored xpub is at a non-standard depth but is a valid extended