Some tests left timers running when they ended, and --forceExit hid them. - Six background test files leave an approval pending, so the background's 20-second timer ran forever. Each file now stubs that timer, as two blocks in tests/backgroundApproval.test.js already did. - Five popup test files show a flash message, and its timer outlived the test. They now run on fake timers. Both test scripts in package.json drop --forceExit. Model: opus-5-5
605 lines
20 KiB
JavaScript
605 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, index: 0, 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,
|
|
index: 0,
|
|
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);
|
|
});
|
|
|
|
// The wallet holds the imported key but will not export it, so the copy
|
|
// must name every key the user may hold that reaches the addresses
|
|
// outside it.
|
|
test("it names the keys that reach the addresses outside this wallet", () => {
|
|
const body = defect.paragraphs.join(" ");
|
|
|
|
expect(body).toMatch(/private key export are disabled/);
|
|
expect(body).toMatch(
|
|
/with the extended private key you imported, if you kept a copy of it, or with the master extended private key or recovery phrase it came from, in software that repeats the same non-standard derivation/,
|
|
);
|
|
});
|
|
|
|
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(() => {
|
|
// showFlash() starts a timer that clears the message two seconds
|
|
// later. Each test here runs on fake timers and drops them in
|
|
// afterEach, so that timer never outlives the test.
|
|
jest.useFakeTimers();
|
|
|
|
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(() => {
|
|
jest.useRealTimers();
|
|
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: "0x1",
|
|
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" },
|
|
brokenXprvWallet().addresses[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" },
|
|
healthyXprvWallet().addresses[0],
|
|
masterXprv(VECTOR_PHRASE),
|
|
);
|
|
|
|
expect(signer.address).toBe(healthyXprvWallet().addresses[0].address);
|
|
});
|
|
});
|