Files
AutistMask/tests/walletDefects.test.js
T
clawbot 5d26283cd0
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
test: cover every control that refuses a defective wallet (closes #254)
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
2026-10-05 12:59:15 +02:00

583 lines
20 KiB
JavaScript

// Tests for the stored-state half of the non-master extended key problem.
//
// Refusing a non-master xprv at import time does nothing for a wallet that is
// 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 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");
const wallet = require("../src/shared/wallet");
const {
walletDefect,
walletDefectHtml,
NON_MASTER_XPRV,
} = require("../src/shared/walletDefects");
// BIP-39 test vector phrase, published; never used for real funds.
const VECTOR_PHRASE =
"test test test test test test test test test test test junk";
function seedNode(phrase) {
return HDNodeWallet.fromSeed(Mnemonic.fromPhrase(phrase, "").computeSeed());
}
// The master (depth-0) key, which is what the import flow accepts today.
function masterXprv(phrase) {
return seedNode(phrase).extendedKey;
}
// The account-level (depth-3) key m/44'/60'/0'. A normal thing for a user to
// hold, and what the import flow used to accept.
function accountXprv(phrase) {
return seedNode(phrase).derivePath("m/44'/60'/0'").extendedKey;
}
// The wallet record the CURRENT import path writes for a master key: the
// neutered m/44'/60'/0'/0 node, four levels below a depth-0 key.
function healthyXprvWallet(name = "Master") {
const { xpub, firstAddress } = wallet.hdWalletFromXprv(
masterXprv(VECTOR_PHRASE),
);
return {
name,
type: "xprv",
xpub,
nextIndex: 1,
encryptedSecret: "irrelevant-to-these-tests",
addresses: [{ address: firstAddress, balance: "0.0000" }],
};
}
// The wallet record the PRE-#210 import path wrote for an account-level key:
// the same four levels, but derived as a relative path *beneath* the key, so
// the stored xpub sits at depth 3 + 4 = 7. Built here the way the old code
// built it rather than by calling the module under test, which now refuses.
function brokenXprvWallet(name = "Imported xprv") {
const node = HDNodeWallet.fromExtendedKey(
accountXprv(VECTOR_PHRASE),
).derivePath("44'/60'/0'/0");
return {
name,
type: "xprv",
xpub: node.neuter().extendedKey,
nextIndex: 1,
encryptedSecret: "irrelevant-to-these-tests",
addresses: [
{ address: node.deriveChild(0).address, balance: "0.0000" },
],
};
}
describe("the fixtures are what the two import paths actually produced", () => {
test("a master import stores a depth-4 xpub", () => {
expect(
HDNodeWallet.fromExtendedKey(healthyXprvWallet().xpub).depth,
).toBe(4);
});
test("the pre-fix account-level import stored a depth-7 xpub", () => {
expect(
HDNodeWallet.fromExtendedKey(brokenXprvWallet().xpub).depth,
).toBe(7);
});
});
describe("walletDefect", () => {
test("names the defect on a stored non-master xprv wallet", () => {
const defect = walletDefect(brokenXprvWallet());
expect(defect).not.toBeNull();
expect(defect.id).toBe(NON_MASTER_XPRV);
});
test("a depth-0 xprv wallet has no defect", () => {
expect(walletDefect(healthyXprvWallet())).toBeNull();
});
test("hd and key wallets are never assessed", () => {
expect(
walletDefect({ type: "hd", xpub: brokenXprvWallet().xpub }),
).toBe(null);
expect(walletDefect({ type: "key" })).toBeNull();
});
test("an xprv wallet whose xpub cannot be parsed makes no claim", () => {
// No basis to say the key was non-master, so nothing is asserted
// about it rather than guessing.
expect(walletDefect({ type: "xprv", xpub: "not-a-key" })).toBeNull();
expect(walletDefect({ type: "xprv" })).toBeNull();
});
test("nothing about the wallet record is modified by the check", () => {
const w = brokenXprvWallet();
const before = JSON.stringify(w);
walletDefect(w);
expect(JSON.stringify(w)).toBe(before);
});
});
describe("the explanatory copy", () => {
const defect = walletDefect(brokenXprvWallet());
test("every sentence of it is a full sentence", () => {
for (const text of [defect.heading, ...defect.paragraphs]) {
expect(text).toMatch(/^[A-Z]/);
expect(text.trimEnd()).toMatch(/\.$/);
}
});
test("it says what was derived wrongly and that these are not the standard addresses", () => {
const body = defect.paragraphs.join(" ");
expect(body).toContain("not a master key");
expect(body).toMatch(/standard path/);
});
test("it does not claim the funds are safe and does not claim a loss", () => {
const all = [defect.heading, ...defect.paragraphs].join(" ");
expect(all).not.toMatch(/\bsafe\b/i);
expect(all).not.toMatch(/\blost\b|\bstolen\b|\bgone\b/i);
});
test("it says the wallet is not deleted and what the user can do", () => {
const body = defect.paragraphs.join(" ");
expect(body).toMatch(/until you delete it yourself/);
expect(body).toMatch(/recovery phrase/);
});
test("it uses the project's vocabulary", () => {
const all = [
defect.heading,
...defect.paragraphs,
defect.shortMessage,
].join(" ");
expect(all).not.toMatch(/seed phrase|mnemonic|passphrase/i);
expect(all).not.toMatch(/\baccounts?\b/i);
});
});
describe("walletDefectHtml", () => {
test("renders the heading and every paragraph for a defective wallet", () => {
const defect = walletDefect(brokenXprvWallet());
const html = walletDefectHtml(brokenXprvWallet());
expect(html).toContain(defect.heading);
for (const p of defect.paragraphs) {
expect(html).toContain(p);
}
});
test("renders nothing at all for a healthy wallet", () => {
expect(walletDefectHtml(healthyXprvWallet())).toBe("");
});
});
describe("the wallet list", () => {
let home;
let state;
beforeAll(() => {
global.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
runtime: { sendMessage: () => {} },
};
home = require("../src/popup/views/home");
state = require("../src/shared/state").state;
});
afterEach(() => {
state.wallets = [];
state.activeAddress = null;
});
test("a stored depth-3 xprv wallet renders the explanation", () => {
state.wallets = [brokenXprvWallet("Imported xprv")];
const html = home.walletListHtml();
expect(html).toContain(walletDefect(state.wallets[0]).heading);
expect(html).toContain("Imported xprv");
});
test("it does not offer to derive further addresses from that wallet", () => {
state.wallets = [brokenXprvWallet()];
expect(home.walletListHtml()).not.toContain("btn-add-address");
});
test("a normal depth-0 xprv wallet renders exactly as it did before", () => {
state.wallets = [healthyXprvWallet("Master")];
const html = home.walletListHtml();
expect(html).not.toContain(walletDefect(brokenXprvWallet()).heading);
expect(html).toContain("btn-add-address");
expect(html).toContain(state.wallets[0].addresses[0].address);
});
test("the defective wallet's notice does not bleed onto a healthy one", () => {
state.wallets = [brokenXprvWallet("Broken"), healthyXprvWallet("Fine")];
const html = home.walletListHtml();
const healthyPart = html.slice(html.indexOf("Fine"));
expect(html).toContain(walletDefect(state.wallets[0]).heading);
expect(healthyPart).not.toContain(
walletDefect(state.wallets[0]).heading,
);
expect(healthyPart).toContain("btn-add-address");
});
});
// 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
// key; deriving from it is what the list render already does.
const w = brokenXprvWallet();
expect(() => wallet.deriveAddressFromXpub(w.xpub, 0)).not.toThrow();
expect(wallet.deriveAddressFromXpub(w.xpub, 0)).toBe(
w.addresses[0].address,
);
});
test("the wallet list renders without throwing", () => {
const { state } = require("../src/shared/state");
const home = require("../src/popup/views/home");
state.wallets = [brokenXprvWallet()];
expect(() => home.walletListHtml()).not.toThrow();
state.wallets = [];
});
test("signing refuses with the named defect rather than a bare failure", () => {
// getSignerForAddress is the backstop behind the UI gate. It must
// still refuse, and it must say why in a sentence the user can read.
let thrown = null;
try {
wallet.getSignerForAddress(
{ type: "xprv" },
0,
accountXprv(VECTOR_PHRASE),
);
} catch (e) {
thrown = e;
}
expect(thrown).not.toBeNull();
expect(thrown.message).toMatch(/master key/);
expect(thrown.message.trimEnd()).toMatch(/\.$/);
});
test("a healthy xprv wallet signs as it always did", () => {
const signer = wallet.getSignerForAddress(
{ type: "xprv" },
0,
masterXprv(VECTOR_PHRASE),
);
expect(signer.address).toBe(healthyXprvWallet().addresses[0].address);
});
});