Compare commits

...
2 Commits
Author SHA1 Message Date
sneak 4a2d0b4a2a test: drive WaitTx's timeout and failed-lookup exits end to end (closes #315)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
Two Chrome end-to-end cases send ETH and leave the wait for its receipt
running. In one, lookups answer "no receipt" until the 60-second deadline
ends the wait with the timeout message. In the other, a new fixture switch
makes every receipt lookup fail, and the sixth failure in a row ends the
wait with the message naming the unreachable network. Both check the exact
message and that Done returns to the address screen. Both wait in real
time: Playwright's clock would apply to every later test, and backdating
the stored broadcast time races the popup's own save.

Model: opus-5-5
2026-10-05 11:43:40 +00:00
clawbot 5d26283cd0 test: cover every control that refuses a defective wallet (closes #254)
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 1s
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
9 changed files with 446 additions and 17 deletions
+9 -1
View File
@@ -376,6 +376,12 @@ reserve while sitting on the same side of the estimate, so swapping the two in
what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
previously correct by reading only.
It also covers both ways the wait for a sent transaction's receipt ends on the
error screen: lookups that still find no receipt 60 seconds after the broadcast,
and six lookups in a row that fail. Each must show its own message, and Done
must lead back to the address screen. Both wait in real time, about a minute
each.
It also covers the **dApp approval round trips** — the one place where the
content script, the inpage provider, the background worker and the approval
popup all have to work together. A local test page is served by the route
@@ -640,7 +646,9 @@ suite outright if its network interception is not in effect.
Measured on this repo's runner: `e2e-chrome` about 1m55s cold, almost all of it
the one-time pull of the pinned ~800MB Playwright layer, and well under a minute
once that layer is cached. `e2e-firefox` about 1m05s cold, and it caches its
Firefox and geckodriver downloads the same way.
Firefox and geckodriver downloads the same way. The `e2e-chrome` figures predate
the two cases that wait for a receipt to end in error, which add about two
minutes of real waiting.
### Element id guard (part of `make check`)
+20
View File
@@ -45,6 +45,26 @@ but the review is broader than any of them.
# Completed Steps
- 2026-10-05: The Chrome end-to-end suite drives both ways the wait for a
transaction's receipt ends on the error screen
([#315](https://git.eeqj.de/sneak/AutistMask/issues/315)): lookups that still
find no receipt 60 seconds after the broadcast end it with the timeout
message, and six lookups that fail in a row end it with the message naming the
unreachable network. Done then returns to the address screen. Both cases wait
in real time, about a minute each. Playwright's clock would apply to every
later test in the run and cannot be removed, and moving the stored broadcast
time back can be undone by the save the popup makes every ten seconds.
- 2026-10-05: Each control that leads to a signature or to the private key has a
test that it refuses a defective wallet before asking for a password
([#254](https://git.eeqj.de/sneak/AutistMask/issues/254)): 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 refuses it too now,
because the popup reopens onto that screen from a saved view. The comments
that said the wallet's key cannot be derived now say that
`getSignerForAddress` refuses it, and the module comment in
`src/shared/walletDefects.js` names both earlier import paths.
- 2026-10-05: The Chrome end-to-end suite drives the private key export screen
as it drives the recovery phrase screen
([#253](https://git.eeqj.de/sneak/AutistMask/issues/253)): the correct
+6 -5
View File
@@ -33,8 +33,8 @@ const { walletDefect } = require("../../shared/walletDefects");
// The defect of the wallet the selected address belongs to, or null. Both the
// send and the private-key export path check it before asking for a password,
// so a wallet that cannot derive its keys says so instead of failing after the
// user has typed one in.
// so a wallet whose key getSignerForAddress refuses says so instead of failing
// after the user has typed one in.
function selectedWalletDefect() {
if (state.selectedWallet === null) return null;
return walletDefect(state.wallets[state.selectedWallet]);
@@ -259,9 +259,10 @@ function init(_ctx) {
$("btn-export-privkey").addEventListener("click", () => {
moreDropdown.classList.add("hidden");
moreBtn.classList.remove("bg-fg", "text-bg");
// There is no private key to export for an address this wallet
// cannot derive. Without this the export screen would take a
// password and then report it as wrong.
// This address's private key can be derived from the stored key,
// but export goes through getSignerForAddress, which refuses a key
// that is not a master key. Without this the export screen would
// take a password and then report that refusal as a wrong password.
const defect = selectedWalletDefect();
if (defect) {
showFlash(defect.shortMessage);
+4 -3
View File
@@ -822,9 +822,10 @@ function setSignButtonBusy(busy) {
}
// Say so on the approval screen itself, and disable the approve button, when
// the address the approval was raised for belongs to a wallet whose keys
// cannot be derived. Without this the screen would take a password and fail
// after deriving it. Reject stays available; the wallet is not touched.
// the address the approval was raised for belongs to a wallet whose key
// getSignerForAddress refuses. Without this the screen would take a password
// and fail after deriving it. Reject stays available; the wallet is not
// touched.
// Returns true when it gated.
function gateOnWalletDefect(errorId, buttonId, address) {
const owner = findWalletFor(address);
+10 -1
View File
@@ -21,6 +21,7 @@ const {
} = require("./helpers");
const { state, currentNetwork } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
const { decryptWithPassword } = require("../../shared/vault");
const { formatUsd, getPrice } = require("../../shared/prices");
const { getProvider } = require("../../shared/balances");
@@ -537,6 +538,15 @@ function init(_ctx) {
onViewLeave("confirm-tx", clearPassword);
$("btn-confirm-send").addEventListener("click", async () => {
const wallet = state.wallets[state.selectedWallet];
// Every Send button refuses a defective wallet before this screen,
// but the popup also reopens onto it from a saved view.
const defect = walletDefect(wallet);
if (defect) {
showError("confirm-tx-password-error", defect.shortMessage);
return;
}
const password = $("confirm-tx-password").value;
if (!password) {
showError(
@@ -546,7 +556,6 @@ function init(_ctx) {
return;
}
const wallet = state.wallets[state.selectedWallet];
let decryptedSecret;
hideError("confirm-tx-password-error");
+11 -5
View File
@@ -13,11 +13,17 @@ const NON_MASTER_XPRV = "non-master-xprv";
// An "xprv" wallet stores the neutered BIP-44 Ethereum node, four levels below
// the key that was imported: the current import path derives the absolute
// m/44'/60'/0'/0 from a depth-0 key, and the pre-#210 path derived the same
// four levels as a relative path beneath whatever depth it was given. A master
// import therefore stores a depth-4 xpub and a depth-d import stores depth
// d + 4, which makes the stored xpub an exact read on the imported key's
// depth — and it is readable without the password, unlike the key itself.
// m/44'/60'/0'/0 from a depth-0 key, and the path before #210 (57959b7)
// derived the same four levels as a relative path beneath whatever depth it
// was given. A master import therefore stores a depth-4 xpub and a depth-d
// import stores depth d + 4, which makes the stored xpub an exact read on the
// imported key's depth — and it is readable without the password, unlike the
// key itself.
//
// The first import path (7a7f9c5) does not fit: it stored the imported key's
// own xpub with no derivation, so a wallet it wrote is judged wrongly here (a
// master import as defective, a depth-4 import as sound). 57959b7 replaced it
// in the same push, and no tag contains it.
const BIP44_ETH_XPUB_DEPTH = 4;
const DEFECTS = {
+12
View File
@@ -448,6 +448,16 @@ function rpcReply(req, opts, report) {
return Object.assign(envelope, { result: ethCallResult(req, opts) });
}
if (req.method === "eth_getTransactionReceipt") {
// A lookup that fails, which the wait screen counts differently from
// one that answers "not mined yet" (README.md, WaitTx).
if (opts.failReceiptLookup) {
return Object.assign(envelope, {
error: {
code: -32000,
message: "e2e fixture: receipt lookup failed",
},
});
}
const hash = Array.isArray(req.params) ? req.params[0] : null;
return Object.assign(envelope, {
result: opts.seedReceipt && hash ? transactionReceipt(hash) : null,
@@ -607,6 +617,8 @@ function traceEnabled(raw) {
* symbol is markup; read at request time.
* @param {boolean} [opts.seedReceipt] answer eth_getTransactionReceipt with a
* confirmed receipt instead of null, so a wait screen resolves.
* @param {boolean} [opts.failReceiptLookup] answer eth_getTransactionReceipt
* with an error, so every receipt lookup fails; read at request time.
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
* Promise<string|null>}>}
*/
+80
View File
@@ -2699,6 +2699,84 @@ test("a token that lies about decimals() at signing time broadcasts nothing (#30
}
});
// ------------------------------ the wait for a receipt ending in error (#315)
//
// README.md (WaitTx) documents two ways the wait ends on the error screen: a
// lookup that answers "no receipt" 60 seconds or more after the broadcast, and
// six lookups in a row that fail. They are different facts with different
// messages, so each is driven to its own.
//
// Both wait in real time, about a minute each. The wait reads the popup's own
// clock and its own ten-second timer. Playwright's clock would move both, but
// it is installed on the whole browser context and cannot be removed, so every
// later test would run on it. Moving the stored broadcast time back instead can
// be undone by the save the popup makes every ten seconds.
// Send ETH from the address screen and stop on the wait for its receipt.
async function sendEthToWait(env) {
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
await waitForEstimate(env.page);
await fillPasswordAndSend(env.page);
await visible(env.page, "#view-wait-tx", 60000);
}
test("a wait still without a receipt after 60 seconds ends on the timeout message (#315)", async (env) => {
try {
await sendEthToWait(env);
// Lookups run every ten seconds and answer "no receipt", so the one
// that ends the wait comes about 60 seconds after the broadcast.
await visible(env.page, "#view-error-tx", 90000);
const message = (
await env.page.locator("#error-tx-message").innerText()
).trim();
assert(
message ===
"Transaction was not confirmed within 60 seconds. It may still confirm later — check Etherscan.",
"the wait did not end on the timeout message: " +
JSON.stringify(message),
);
await env.page.click("#btn-error-tx-done");
await visible(env.page, "#view-address");
} finally {
await backToAddressAfterSend(env);
}
});
test("six failed receipt lookups in a row end on the unreachable-network message (#315)", async (env) => {
// Each failed lookup is logged through log.errorf, i.e. console.error.
// Exactly six are declared: a wait that ended sooner leaves one unmatched,
// and one that went on logs a seventh, and either fails this test.
for (let i = 1; i <= 6; i++) {
env.errors.expect(
"failed receipt lookup " + i + " of 6",
/poll receipt failed/,
);
}
env.routeOpts.failReceiptLookup = true;
try {
await sendEthToWait(env);
await visible(env.page, "#view-error-tx", 90000);
const message = (
await env.page.locator("#error-tx-message").innerText()
).trim();
assert(
message ===
"The network could not be reached to check this transaction — 6 lookups failed in a row. Check the RPC URL in Settings. The transaction may still have confirmed — check Etherscan.",
"the wait did not end on the unreachable-network message: " +
JSON.stringify(message),
);
await env.page.click("#btn-error-tx-done");
await visible(env.page, "#view-address");
} finally {
env.routeOpts.failReceiptLookup = false;
await backToAddressAfterSend(env);
}
});
// ------------------------------------------- hostile token symbol (#307)
//
// The reproduction from the issue, in the real browser against the real
@@ -4302,6 +4380,8 @@ async function main() {
// Whether eth_getTransactionReceipt confirms a transaction rather than
// answering "not mined yet".
seedReceipt: false,
// Whether eth_getTransactionReceipt fails instead of answering (#315).
failReceiptLookup: false,
// Every raw signed transaction handed to eth_sendRawTransaction, in
// order. The dApp transaction round trip asserts against these bytes
// rather than against anything the extension reported about them.
+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