Compare commits
1 Commits
c66cec2f8b
...
c8e193335d
| Author | SHA1 | Date | |
|---|---|---|---|
| c8e193335d |
28
README.md
28
README.md
@@ -169,34 +169,6 @@ 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
|
what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
|
||||||
previously correct by reading only.
|
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
|
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
|
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
|
consumes exactly one matching record, and a declaration nothing matched fails
|
||||||
|
|||||||
11
TODO.md
11
TODO.md
@@ -59,17 +59,6 @@ undefined identifiers, which is how
|
|||||||
walks `TOKENS` asserting no bundled token is filtered at its own address,
|
walks `TOKENS` asserting no bundled token is filtered at its own address,
|
||||||
which is the walk the suite lacked
|
which is the walk the suite lacked
|
||||||
([#276](https://git.eeqj.de/sneak/AutistMask/issues/276)).
|
([#276](https://git.eeqj.de/sneak/AutistMask/issues/276)).
|
||||||
- 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
|
- 2026-08-12: The known-symbol spoof rule now judges the symbol a user actually
|
||||||
sees. `isSpoofedSymbol()` normalizes before the lookup — NFKC, then every
|
sees. `isSpoofedSymbol()` normalizes before the lookup — NFKC, then every
|
||||||
character that paints nothing removed (the format and default-ignorable
|
character that paints nothing removed (the format and default-ignorable
|
||||||
|
|||||||
@@ -23,8 +23,6 @@
|
|||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const { Transaction } = require("ethers");
|
|
||||||
|
|
||||||
// Fictional ERC-20 used to seed the transaction-detail test. The symbol
|
// Fictional ERC-20 used to seed the transaction-detail test. The symbol
|
||||||
// must not collide with any entry in src/shared/tokenList.js, or
|
// must not collide with any entry in src/shared/tokenList.js, or
|
||||||
// isSpoofedSymbol() in src/shared/transactions.js drops the transfer as a
|
// isSpoofedSymbol() in src/shared/transactions.js drops the transfer as a
|
||||||
@@ -64,84 +62,6 @@ function word(value) {
|
|||||||
return "0x" + BigInt(value).toString(16).padStart(64, "0");
|
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
|
// ------------------------------------------------------------ fee fixture
|
||||||
//
|
//
|
||||||
// The confirmation screen carries two different numbers for the same
|
// The confirmation screen carries two different numbers for the same
|
||||||
@@ -181,11 +101,6 @@ const RPC_RESULTS = {
|
|||||||
eth_estimateGas: hex(GAS_LIMIT),
|
eth_estimateGas: hex(GAS_LIMIT),
|
||||||
eth_getTransactionCount: "0x0",
|
eth_getTransactionCount: "0x0",
|
||||||
eth_maxPriorityFeePerGas: hex(PRIORITY_FEE_WEI),
|
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
|
// The "latest" block, which ethers' getFeeData() reads baseFeePerGas from
|
||||||
@@ -349,31 +264,6 @@ function rpcReply(req, opts, report) {
|
|||||||
if (req.method === "eth_getBlockByNumber") {
|
if (req.method === "eth_getBlockByNumber") {
|
||||||
return Object.assign(envelope, { result: latestBlock() });
|
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) {
|
if (req.method === "eth_estimateGas" && opts.failGasEstimate) {
|
||||||
// A refusal the node itself would produce, not a transport error:
|
// A refusal the node itself would produce, not a transport error:
|
||||||
// this is the shape the confirmation screen has to turn into
|
// this is the shape the confirmation screen has to turn into
|
||||||
@@ -485,8 +375,6 @@ function traceEnabled(raw) {
|
|||||||
* node-side refusal.
|
* node-side refusal.
|
||||||
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an
|
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an
|
||||||
* eth_estimateGas until this is cleared again.
|
* 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) =>
|
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
|
||||||
* Promise<string|null>}>}
|
* Promise<string|null>}>}
|
||||||
*/
|
*/
|
||||||
@@ -532,18 +420,6 @@ async function installNetworkStubs(ctx, opts) {
|
|||||||
return handleRpc(route, req.postData(), opts, report);
|
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
|
// Blockscout v2
|
||||||
if (p.includes("/api/v2/")) {
|
if (p.includes("/api/v2/")) {
|
||||||
if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) {
|
if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) {
|
||||||
@@ -632,8 +508,6 @@ async function installNetworkStubs(ctx, opts) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
installNetworkStubs,
|
installNetworkStubs,
|
||||||
DAPP_ORIGIN,
|
|
||||||
DAPP_URL,
|
|
||||||
FEE_ESTIMATE_WEI,
|
FEE_ESTIMATE_WEI,
|
||||||
FEE_RESERVE_WEI,
|
FEE_RESERVE_WEI,
|
||||||
STUB_COUNTERPARTY,
|
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