test: drive the EIP-1193 dApp approval round trips in the browser (closes #183)
All checks were successful
check / check (push) Successful in 30s
All checks were successful
check / check (push) Successful in 30s
The seam between the content script, the inpage provider, the background worker
and the approval popup had no coverage at all. The unit suite covers each side
in isolation, so a wallet that signed the wrong payload, handed back a signature
from the wrong key, hung on a rejected prompt, or put the user's password back
on the extension messaging boundary would have passed everything.
Ten end-to-end tests now drive it. The route handler serves a local test page on
a reserved-TLD origin; window.ethereum arrives there from the shipped MAIN-world
content script, not from anything the fixture installs, and the page's own
EIP-6963 announcement is required to be that provider by object identity. The
page then speaks eth_requestAccounts, personal_sign, eth_signTypedData_v4 and
eth_sendTransaction through the real prompts, approved and rejected.
The assertions are the point:
- every signature is recovered in the runner with verifyMessage,
verifyTypedData and Transaction.from(), and compared against the active
address read out of extension storage. The background verifies too; nothing
here leans on its verdict.
- the transaction is checked against the raw signed bytes captured at
eth_sendRawTransaction, which the RPC stub now records and answers with the
transaction's real hash. Signer, recipient, value, call data and chain id
are all compared there, and the hash the page received is required to be the
hash of those bytes.
- a rejected prompt must reach the page as a rejection rather than hang or
resolve, and must carry EIP-1193 code 4001 across the boundary. The code is
asserted on the wire because that is where it survives: the inpage provider
rebuilds the rejection as a bare Error, so the calling page catches a message
and no code. Reported, not asserted either way.
- the password must appear in no message the approval window sends to the
background, observed directly by wrapping chrome.runtime.sendMessage before
Approve is clicked, with the response message that would carry it required
to be present so the check cannot pass on an empty record.
Every one of those was run against a deliberately broken variant and seen to
fail: a corrupted recovered signer, a payload carrying the password again, a
provider that resolves instead of rejecting, and approval screens displaying the
wrong message, value and call data.
Two honest limits. The RPC is stubbed throughout, so this does not discharge a
real dApp with real funds against a real network. And the site-connection prompt
goes through chrome.action.openPopup(), whose browser-action popup headless
Chromium will not expose as a page, so that one prompt is driven at the URL the
extension puts on the action instead — same page, same approval id, but a real
toolbar click is not observable from a headless harness. Both are stated in
README.md rather than presented as covered.
Test-only: nothing under src/ changes.
This commit is contained in:
28
README.md
28
README.md
@@ -169,6 +169,34 @@ 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 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
|
||||
handler on a reserved-TLD origin, gets `window.ethereum` from the shipped
|
||||
`MAIN`-world content script like any other page, and drives
|
||||
`eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4` and
|
||||
`eth_sendTransaction` through the real prompts. Every signature is recovered in
|
||||
the runner and compared against the active address, the transaction assertions
|
||||
run against the raw signed transaction captured at `eth_sendRawTransaction`
|
||||
rather than against anything the extension reported, rejecting each prompt is
|
||||
required to return a rejection to the page rather than hang or resolve, and the
|
||||
password is required to be absent from every message the approval window sends
|
||||
to the background — with the message that would carry it required to be present,
|
||||
so that check cannot pass by observing nothing. That last one is the standing
|
||||
floor under [#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
|
||||
|
||||
Three limits of that coverage, none of them papered over. The RPC is stubbed
|
||||
throughout, so this is **not** a real dApp against a real network with real
|
||||
funds; that remains a human pass before 1.0.0. The site-connection prompt is
|
||||
raised through `chrome.action.openPopup()`, and headless Chromium's
|
||||
browser-action popup is not a page Playwright can see or click, so that one
|
||||
prompt is driven at the URL the extension itself puts on the action — the same
|
||||
page and the same approval id, but whether a real toolbar click shows it is not
|
||||
observable here. And the EIP-1193 error code does not survive the last hop: the
|
||||
rejection that crosses the boundary carries code 4001 and is asserted to, but
|
||||
`src/content/inpage.js` rebuilds it as `new Error(message)`, so the calling page
|
||||
catches an error with no `code` property.
|
||||
|
||||
Any test that drives a failure path on purpose declares the `console.error` it
|
||||
is about to provoke, via `errors.expect()`. That is not a mute: the declaration
|
||||
consumes exactly one matching record, and a declaration nothing matched fails
|
||||
|
||||
11
TODO.md
11
TODO.md
@@ -45,6 +45,17 @@ undefined identifiers, which is how
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-08-12: The dApp approval round trips are driven end to end in the
|
||||
browser. A test page served by the harness speaks EIP-1193 to the real inpage
|
||||
provider through the real content script, background worker and approval popup
|
||||
for `eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4` and
|
||||
`eth_sendTransaction`. Every signature is recovered and compared against the
|
||||
active address, the transaction is checked against the bytes handed to the
|
||||
stubbed RPC, each rejection must reach the page as a rejection, and the
|
||||
password must appear in no message the approval window sends — the assertion
|
||||
that gives [#157](https://git.eeqj.de/sneak/AutistMask/issues/157) a permanent
|
||||
floor. This does not discharge a real dApp with real funds against mainnet
|
||||
([#183](https://git.eeqj.de/sneak/AutistMask/issues/183)).
|
||||
- 2026-08-12: The known-symbol spoof rule now judges the symbol a user actually
|
||||
sees. `isSpoofedSymbol()` normalizes before the lookup — NFKC, then every
|
||||
character that paints nothing removed (the format and default-ignorable
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
|
||||
"use strict";
|
||||
|
||||
const { Transaction } = require("ethers");
|
||||
|
||||
// Fictional ERC-20 used to seed the transaction-detail test. The symbol
|
||||
// must not collide with any entry in src/shared/tokenList.js, or
|
||||
// isSpoofedSymbol() in src/shared/transactions.js drops the transfer as a
|
||||
@@ -62,6 +64,84 @@ function word(value) {
|
||||
return "0x" + BigInt(value).toString(16).padStart(64, "0");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- dApp fixture
|
||||
//
|
||||
// The origin the EIP-1193 test page is served from, and the page itself.
|
||||
//
|
||||
// It is a fixture like every other one in this file: the route handler
|
||||
// fulfils the navigation from the string below, so the page never comes
|
||||
// from a remote origin and nothing about the dApp round trips leaves the
|
||||
// container. `.test` is reserved by RFC 6761 and has no owner to reach in
|
||||
// the first place; the launch arguments map every host to NOTFOUND anyway.
|
||||
//
|
||||
// What the page deliberately does NOT do is load a provider. window.ethereum
|
||||
// is put there by the shipped manifest's MAIN-world content script, exactly
|
||||
// as it is on any http(s) page a user visits, so what these tests speak to
|
||||
// is the real inpage provider and not a copy the harness wired up.
|
||||
const DAPP_ORIGIN = "https://dapp.e2e.test";
|
||||
const DAPP_URL = DAPP_ORIGIN + "/";
|
||||
|
||||
// Requests are parked rather than awaited. An approval prompt only exists
|
||||
// while its call is in flight, so a test that awaited the promise could
|
||||
// never drive the popup that has to settle it; start() files the promise
|
||||
// under a key and settle() collects it once the prompt has been dealt with.
|
||||
//
|
||||
// The rejection branch records `code` as it arrives. EIP-1193 says a user
|
||||
// rejection is a ProviderRpcError carrying code 4001; what the page can
|
||||
// actually see is recorded here rather than assumed, and asserted in run.js.
|
||||
//
|
||||
// The message log is the page's half of the boundary observation: every
|
||||
// AUTISTMASK_* message that crosses between this page and the content
|
||||
// script, in both directions, verbatim.
|
||||
const DAPP_HTML = [
|
||||
"<!doctype html>",
|
||||
'<html lang="en">',
|
||||
"<head>",
|
||||
'<meta charset="utf-8">',
|
||||
"<title>AutistMask e2e dApp</title>",
|
||||
// Inline and empty: without it Chromium asks for /favicon.ico, which
|
||||
// the unstubbed-request guard would report as escaping traffic.
|
||||
'<link rel="icon" href="data:,">',
|
||||
"</head>",
|
||||
"<body>",
|
||||
"<h1>AutistMask e2e dApp</h1>",
|
||||
"<script>",
|
||||
"window.__dapp = {",
|
||||
" messages: [],",
|
||||
" calls: {},",
|
||||
" start: function (key, method, params) {",
|
||||
" window.__dapp.calls[key] = window.ethereum",
|
||||
" .request({ method: method, params: params })",
|
||||
" .then(",
|
||||
" function (result) {",
|
||||
" return { settled: 'resolved', result: result };",
|
||||
" },",
|
||||
" function (error) {",
|
||||
" return {",
|
||||
" settled: 'rejected',",
|
||||
" message: String((error && error.message) || error),",
|
||||
" hasCode: !!error && 'code' in Object(error),",
|
||||
" code: error ? error.code : undefined,",
|
||||
" };",
|
||||
" },",
|
||||
" );",
|
||||
" },",
|
||||
" settle: function (key) {",
|
||||
" return window.__dapp.calls[key];",
|
||||
" },",
|
||||
"};",
|
||||
"window.addEventListener('message', function (event) {",
|
||||
" if (event.source !== window) return;",
|
||||
" var d = event.data;",
|
||||
" if (!d || typeof d.type !== 'string') return;",
|
||||
" if (d.type.indexOf('AUTISTMASK') !== 0) return;",
|
||||
" window.__dapp.messages.push(d);",
|
||||
"});",
|
||||
"</script>",
|
||||
"</body>",
|
||||
"</html>",
|
||||
].join("\n");
|
||||
|
||||
// ------------------------------------------------------------ fee fixture
|
||||
//
|
||||
// The confirmation screen carries two different numbers for the same
|
||||
@@ -101,6 +181,11 @@ const RPC_RESULTS = {
|
||||
eth_estimateGas: hex(GAS_LIMIT),
|
||||
eth_getTransactionCount: "0x0",
|
||||
eth_maxPriorityFeePerGas: hex(PRIORITY_FEE_WEI),
|
||||
// "not mined yet", which is what a node answers for a transaction it has
|
||||
// only just accepted. The wait screen the dApp transaction approval hands
|
||||
// off to polls this every 10 seconds; leaving it unstubbed would report
|
||||
// the poll as escaping traffic the moment a test outlived one tick.
|
||||
eth_getTransactionReceipt: null,
|
||||
};
|
||||
|
||||
// The "latest" block, which ethers' getFeeData() reads baseFeePerGas from
|
||||
@@ -264,6 +349,31 @@ function rpcReply(req, opts, report) {
|
||||
if (req.method === "eth_getBlockByNumber") {
|
||||
return Object.assign(envelope, { result: latestBlock() });
|
||||
}
|
||||
// The end of the dApp transaction round trip: the raw signed transaction
|
||||
// the background hands to the node. It is recorded verbatim so a test can
|
||||
// recover the signer from the exact bytes that were broadcast, rather than
|
||||
// from anything the extension reported about them.
|
||||
//
|
||||
// The reply must be the transaction's real hash. ethers compares the hash
|
||||
// the node returns against the one it computes itself and throws on a
|
||||
// mismatch, so a constant here would fail the broadcast for a reason that
|
||||
// has nothing to do with what is being tested.
|
||||
if (req.method === "eth_sendRawTransaction") {
|
||||
const raw = Array.isArray(req.params) ? req.params[0] : null;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = Transaction.from(raw);
|
||||
} catch {
|
||||
report("eth_sendRawTransaction with an undecodable transaction");
|
||||
return Object.assign(envelope, {
|
||||
error: { code: -32000, message: "undecodable transaction" },
|
||||
});
|
||||
}
|
||||
if (Array.isArray(opts.broadcastTransactions)) {
|
||||
opts.broadcastTransactions.push(raw);
|
||||
}
|
||||
return Object.assign(envelope, { result: parsed.hash });
|
||||
}
|
||||
if (req.method === "eth_estimateGas" && opts.failGasEstimate) {
|
||||
// A refusal the node itself would produce, not a transport error:
|
||||
// this is the shape the confirmation screen has to turn into
|
||||
@@ -375,6 +485,8 @@ function traceEnabled(raw) {
|
||||
* node-side refusal.
|
||||
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an
|
||||
* eth_estimateGas until this is cleared again.
|
||||
* @param {string[]} [opts.broadcastTransactions] every raw signed
|
||||
* transaction handed to eth_sendRawTransaction, appended in order.
|
||||
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
|
||||
* Promise<string|null>}>}
|
||||
*/
|
||||
@@ -420,6 +532,18 @@ async function installNetworkStubs(ctx, opts) {
|
||||
return handleRpc(route, req.postData(), opts, report);
|
||||
}
|
||||
|
||||
// The local EIP-1193 test page. Served from here so the dApp round
|
||||
// trips run against a real http(s) origin — which is what makes the
|
||||
// shipped content scripts inject at all — without any remote origin
|
||||
// being involved.
|
||||
if (url.origin === DAPP_ORIGIN && p === "/") {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/html; charset=utf-8",
|
||||
body: DAPP_HTML,
|
||||
});
|
||||
}
|
||||
|
||||
// Blockscout v2
|
||||
if (p.includes("/api/v2/")) {
|
||||
if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) {
|
||||
@@ -508,6 +632,8 @@ async function installNetworkStubs(ctx, opts) {
|
||||
|
||||
module.exports = {
|
||||
installNetworkStubs,
|
||||
DAPP_ORIGIN,
|
||||
DAPP_URL,
|
||||
FEE_ESTIMATE_WEI,
|
||||
FEE_RESERVE_WEI,
|
||||
STUB_COUNTERPARTY,
|
||||
|
||||
986
tests/e2e/run.js
986
tests/e2e/run.js
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user