The blocklist URL in shipped code named a competitor and pointed at a moving ref, and the extension re-fetched from it every 24 hours, which also meant a third party decided what this wallet warns about. All of that is gone. script/vendor-blocklist fetches upstream at a pinned commit, verifies the sha256 of the bytes that commit serves, and writes src/shared/phishingBlocklist.json. It is build-time tooling, never shipped, and the one place in the repo that names the upstream project; a source reference nobody can verify is not a source reference. The artifact stores truncated sha256 digests rather than domain names. That is what censors it: the previous file contained the competitor's name 6,475 times, as phishing domains impersonating them, and not one of those domains is dropped. It also makes lookups a binary search over a fixed-width string, so nothing is built at module load — which matters on MV3, where the worker re-evaluates the module on every wake — and takes the file from 8.7 MB to 1.7 MB. script/check-censored enforces the rest: it reads the name out of the vendoring script rather than repeating it, and fails on any occurrence in the working tree or under dist/ that is not one of the two literals shipped code cannot avoid. It runs in make check, which inspects dist/ when there is one and says loudly when there is not, and again with --require-dist at the end of every make build. Removing the runtime fetch retires the delta, the extension-storage persistence and the 24-hour alarm from #158. A retired alarm is now cleared rather than left waking the worker forever on installs that already have it. The e2e suite drives the warning end to end from a real blocklisted origin served as a real http(s) site, with a control asserting the banner stays hidden for one that is not listed. Its service-worker interception canary needed a new anchor, since the startup fetch it used to watch for no longer happens: it now wakes the worker with a message and asks it for one throwaway fetch. LICENSE no longer cites a repository that returns 404. eslint.config.js gains one block: script/lib/ holds node programs the shell entrypoints call, and without it they lint with no globals at all.
414 lines
17 KiB
JavaScript
414 lines
17 KiB
JavaScript
// End-to-end harness: launches a real Chromium with the unpacked MV3
|
|
// build loaded, collects every uncaught page error and console.error, and
|
|
// exposes the popup flows the tests drive.
|
|
//
|
|
// This runs inside the pinned Playwright container; see script/test-e2e.
|
|
// It is deliberately NOT part of make check — REPO_POLICIES.md caps
|
|
// make test at 20 seconds and a browser suite does not fit.
|
|
|
|
"use strict";
|
|
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
|
|
const { chromium } = require("playwright-core");
|
|
const { installNetworkStubs, WORKER_PROBE_URL } = require("./network");
|
|
|
|
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
|
const EXT_PATH = path.join(REPO_ROOT, "dist", "chrome");
|
|
|
|
// Page errors that are known, tracked, and deliberately tolerated. Every
|
|
// entry must name the issue that will remove it. This list is the one
|
|
// concession in an otherwise zero-tolerance policy: an uncaught error is
|
|
// how this harness caught issue #150 in the first place.
|
|
//
|
|
// Empty, and worth keeping that way. Its only entry was the WASM
|
|
// CompileError libsodium provoked on every popup load, deleted with #182
|
|
// when both manifests started allowing WASM; the run that used to need it
|
|
// is now the run that proves the fix.
|
|
const ALLOWED_ERRORS = [];
|
|
|
|
function isAllowed(text) {
|
|
return ALLOWED_ERRORS.some((a) => a.pattern.test(text));
|
|
}
|
|
|
|
// Collects every uncaught page error, console.error and unstubbed
|
|
// request, and hands each one to exactly one reporter.
|
|
//
|
|
// This deliberately has NO window API. It used to expose mark()/since()
|
|
// so a test could ask for "the errors since I started", and that shape
|
|
// produced a green run that proved nothing twice over: first the mark
|
|
// started after test 1, so everything recorded during launch was
|
|
// discarded, then the tail after the final test was never read at all. In
|
|
// both cases a record fell outside somebody's window and vanished, which
|
|
// is the precise failure this harness exists to prevent.
|
|
//
|
|
// So there is no window left to fall outside of. take() is the only
|
|
// reader and it always takes everything outstanding, so successive takes
|
|
// partition the entire record stream with no gaps, and the runner turns
|
|
// every record it reads into a failure.
|
|
//
|
|
// Observation ends when the browser context is closed. Nothing records
|
|
// after that — the route handler and the console listeners are gone with
|
|
// the context — so there is no post-teardown phase to collect, and this
|
|
// class deliberately offers no mechanism pretending to cover one.
|
|
//
|
|
// One narrow exception exists, and it is not a mute: expect(). A test that
|
|
// drives a failure path on purpose — a refused gas estimate, say — provokes
|
|
// the console.error the code is supposed to emit, and that error is the
|
|
// behaviour under test rather than an escape. Declaring it consumes exactly
|
|
// one matching record and no more, and an expectation nothing matched fails
|
|
// its test just as an unexpected error does. So it cannot be used to
|
|
// silence anything: it can only assert that a specific error happened.
|
|
class ErrorCollector {
|
|
constructor() {
|
|
this.entries = [];
|
|
this.taken = 0;
|
|
this.expectations = [];
|
|
}
|
|
|
|
// Declare a console.error this test is about to cause deliberately.
|
|
// `label` names it in the failure message if it never arrives.
|
|
expect(label, pattern) {
|
|
this.expectations.push({ label, pattern, matched: false });
|
|
}
|
|
|
|
// Declared expectations that nothing matched, clearing the list so each
|
|
// test starts with none outstanding.
|
|
unmatchedExpectations() {
|
|
const out = this.expectations
|
|
.filter((e) => !e.matched)
|
|
.map((e) => e.label);
|
|
this.expectations = [];
|
|
return out;
|
|
}
|
|
|
|
record(kind, text) {
|
|
const line = kind + ": " + String(text).split("\n")[0];
|
|
if (isAllowed(line)) return;
|
|
const expected = this.expectations.find(
|
|
(e) => !e.matched && e.pattern.test(line),
|
|
);
|
|
if (expected) {
|
|
expected.matched = true;
|
|
return;
|
|
}
|
|
this.entries.push(line);
|
|
}
|
|
|
|
// Everything recorded since the previous take(). Never yields a
|
|
// record twice and never skips one.
|
|
take() {
|
|
const out = this.entries.slice(this.taken);
|
|
this.taken = this.entries.length;
|
|
return out;
|
|
}
|
|
}
|
|
|
|
function attachErrorListeners(ctx, errors) {
|
|
const attachPage = (page) => {
|
|
page.on("pageerror", (err) => {
|
|
errors.record("pageerror", err.message || String(err));
|
|
});
|
|
page.on("console", (msg) => {
|
|
if (msg.type() === "error") {
|
|
errors.record("console.error", msg.text());
|
|
}
|
|
});
|
|
};
|
|
ctx.pages().forEach(attachPage);
|
|
ctx.on("page", attachPage);
|
|
// Per-page listeners only: the context-level "weberror" event covers
|
|
// the same page exceptions and would double-report them. Playwright
|
|
// exposes no error EVENT for service workers, so an uncaught
|
|
// exception in the background worker is not visible here — everything
|
|
// this suite drives lives in the popup page. That is an error-channel
|
|
// gap only: worker NETWORK traffic is intercepted and reported like
|
|
// any other, and assertWorkerTrafficIntercepted() below fails the run
|
|
// if it ever stops being.
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
// The most recently seen background worker, waiting for one if none has
|
|
// appeared yet. Most recent rather than first: Chrome stops an idle MV3
|
|
// worker and starts a fresh one on the next event, and a handle to a
|
|
// stopped worker cannot be evaluated in.
|
|
async function serviceWorker(ctx) {
|
|
const workers = ctx.serviceWorkers();
|
|
const latest = workers[workers.length - 1];
|
|
if (latest) return latest;
|
|
return ctx.waitForEvent("serviceworker", { timeout: 30000 });
|
|
}
|
|
|
|
// How long to wait for the probe request the worker is asked to make.
|
|
const WORKER_TRAFFIC_TIMEOUT_MS = 30000;
|
|
|
|
// ctx.route() only sees service-worker requests when Playwright runs with
|
|
// PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, which script/test-e2e
|
|
// sets. Without it every fetch the background worker makes goes to the
|
|
// real internet and nothing says so. A harness whose isolation can lapse
|
|
// in silence is worthless, so this does not take the flag on trust: a
|
|
// request the worker itself issues has to show up in the route handler,
|
|
// or the suite refuses to run.
|
|
//
|
|
// The anchor is a probe the harness asks the worker for, not traffic the
|
|
// extension generates on its own. It used to be the phishing blocklist
|
|
// fetch src/background/index.js issued at startup; that fetch is gone —
|
|
// the blocklist is vendored at build time and the extension contacts
|
|
// nobody when it starts — so there is no longer any startup traffic to
|
|
// observe and the check generates its own.
|
|
//
|
|
// Evaluating in the worker straight after launch does not work, and that
|
|
// is not a stale observation: it was tried again here and failed with
|
|
// "Target page, context or browser has been closed" on the first run.
|
|
// Chrome stops the freshly registered worker as soon as it has nothing to
|
|
// do, and the extension no longer gives it anything to do — which is the
|
|
// same change that removed the old anchor. So the probe wakes the worker
|
|
// before it evaluates in it, by sending it a message from an extension
|
|
// page and waiting for the reply: delivering a message is what starts a
|
|
// stopped worker, and a worker that has just answered one is alive.
|
|
// The evaluated fetch is not awaited, so nothing in the worker is held
|
|
// open by the probe either.
|
|
async function wakeWorker(ctx) {
|
|
const sw = await serviceWorker(ctx);
|
|
const extensionId = new URL(sw.url()).host;
|
|
const page = await ctx.newPage();
|
|
try {
|
|
await page.goto(
|
|
"chrome-extension://" + extensionId + "/src/popup/index.html",
|
|
);
|
|
// eth_chainId is answered from local state: it wakes the worker
|
|
// and changes nothing.
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
chrome.runtime.sendMessage(
|
|
{
|
|
type: "AUTISTMASK_RPC",
|
|
method: "eth_chainId",
|
|
params: [],
|
|
},
|
|
() => resolve(null),
|
|
);
|
|
}),
|
|
);
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
}
|
|
|
|
async function probeFromWorker(ctx, url) {
|
|
let lastError = null;
|
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
try {
|
|
await wakeWorker(ctx);
|
|
const sw = await serviceWorker(ctx);
|
|
await sw.evaluate((u) => {
|
|
// Deliberately not awaited and never rejected: what is
|
|
// being observed is that the request reaches the route
|
|
// handler, and an unhandled rejection in the worker would
|
|
// be collected as a suite error if it did not.
|
|
fetch(u).catch(() => {});
|
|
}, url);
|
|
return;
|
|
} catch (e) {
|
|
lastError = e;
|
|
await sleep(500);
|
|
}
|
|
}
|
|
throw new Error(
|
|
"could not ask the background worker to fetch " +
|
|
url +
|
|
", so service-worker interception was never tested. Last " +
|
|
"error: " +
|
|
(lastError && lastError.message),
|
|
);
|
|
}
|
|
|
|
async function assertWorkerTrafficIntercepted(ctx, stubs) {
|
|
await probeFromWorker(ctx, WORKER_PROBE_URL);
|
|
|
|
const seen = await stubs.waitForServiceWorkerTraffic(
|
|
WORKER_TRAFFIC_TIMEOUT_MS,
|
|
);
|
|
if (seen) return seen;
|
|
|
|
// State the observation, not a conclusion. This fires for at least
|
|
// two quite different causes and the harness cannot tell them apart
|
|
// from here, so guessing one of them in the message sends the reader
|
|
// the wrong way.
|
|
throw new Error(
|
|
"observed no service-worker request in the route handler within " +
|
|
WORKER_TRAFFIC_TIMEOUT_MS +
|
|
"ms, although the background worker was asked to fetch " +
|
|
WORKER_PROBE_URL +
|
|
". Two causes are plausible and this check cannot distinguish " +
|
|
"them: (1) service-worker interception is not in effect, so " +
|
|
"that request went to the real internet unobserved — the suite " +
|
|
"must be run through script/test-e2e, which sets " +
|
|
"PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, and a " +
|
|
"Playwright upgrade may have dropped or renamed that flag; " +
|
|
"(2) the probe never ran, because the worker was torn down " +
|
|
"between being handed over and being evaluated in. Either way " +
|
|
"the fix is a replacement mechanism or an honest downgrade of " +
|
|
"the isolation claims in tests/e2e/network.js and README.md — " +
|
|
"not deleting this check",
|
|
);
|
|
}
|
|
|
|
async function launch(routeOpts) {
|
|
if (!fs.existsSync(path.join(EXT_PATH, "manifest.json"))) {
|
|
throw new Error(
|
|
"no unpacked build at " +
|
|
EXT_PATH +
|
|
" — run make build before the e2e suite",
|
|
);
|
|
}
|
|
|
|
const userDir = fs.mkdtempSync(path.join(os.tmpdir(), "autistmask-e2e-"));
|
|
const ctx = await chromium.launchPersistentContext(userDir, {
|
|
// channel: "chromium" is load-bearing. The default headless mode
|
|
// uses the headless shell, which silently refuses to load
|
|
// extensions: there is no error at all, the service worker simply
|
|
// never appears. This cost real debugging time once already.
|
|
channel: "chromium",
|
|
headless: true,
|
|
args: [
|
|
"--disable-extensions-except=" + EXT_PATH,
|
|
"--load-extension=" + EXT_PATH,
|
|
// The container runs unprivileged; Chrome's sandbox needs
|
|
// capabilities the harness deliberately does not grant it.
|
|
"--no-sandbox",
|
|
// Belt to the interception braces: nothing that slips past
|
|
// the route handler can resolve a name, so a request that
|
|
// escapes cannot actually reach the internet. Detection is
|
|
// still assertWorkerTrafficIntercepted()'s job — this only
|
|
// bounds the damage while a gap goes unnoticed. Playwright
|
|
// fulfils routed requests without touching the resolver, and
|
|
// it drives the browser over a pipe, so neither is affected.
|
|
"--host-resolver-rules=MAP * ~NOTFOUND",
|
|
],
|
|
});
|
|
|
|
const cleanup = async () => {
|
|
await ctx.close().catch(() => {});
|
|
fs.rmSync(userDir, { recursive: true, force: true });
|
|
};
|
|
|
|
try {
|
|
const errors = new ErrorCollector();
|
|
attachErrorListeners(ctx, errors);
|
|
routeOpts.report = (text) => errors.record("network", text);
|
|
const stubs = await installNetworkStubs(ctx, routeOpts);
|
|
|
|
await assertWorkerTrafficIntercepted(ctx, stubs);
|
|
|
|
// The extension id is derived from the unpacked path, so it
|
|
// changes and must never be hardcoded. It is the host part of the
|
|
// service worker URL.
|
|
const sw = await serviceWorker(ctx);
|
|
const id = new URL(sw.url()).host;
|
|
|
|
return {
|
|
ctx,
|
|
errors,
|
|
extensionId: id,
|
|
popupUrl: "chrome-extension://" + id + "/src/popup/index.html",
|
|
close: cleanup,
|
|
};
|
|
} catch (e) {
|
|
// Anything that fails after the browser is up has to tear it down
|
|
// on the way out: an orphaned context keeps node alive forever,
|
|
// turning a clean failure into a hung run.
|
|
await cleanup();
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- flows
|
|
|
|
const PASSWORD = "e2e-harness-password";
|
|
|
|
async function visible(page, selector, timeout = 15000) {
|
|
await page.waitForSelector(selector, { state: "visible", timeout });
|
|
}
|
|
|
|
// An empty WebAssembly module: magic number and version header, no
|
|
// sections. Compiling it in the popup asks the one question that decides
|
|
// libsodium's backend — may this realm compile WebAssembly — of the real
|
|
// page under the real shipped manifest, which is the only place the
|
|
// answer can be observed. Kept independent of src/shared/vault.js on
|
|
// purpose: a bundle asked to grade itself proves less than an outside
|
|
// observation of the same realm.
|
|
const EMPTY_WASM_MODULE = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
|
|
|
|
async function pageCompilesWasm(page) {
|
|
return page.evaluate(async (bytes) => {
|
|
try {
|
|
await WebAssembly.compile(new Uint8Array(bytes));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, EMPTY_WASM_MODULE);
|
|
}
|
|
|
|
async function openPopup(ctx, popupUrl) {
|
|
const page = await ctx.newPage();
|
|
await page.goto(popupUrl);
|
|
return page;
|
|
}
|
|
|
|
// Full wallet creation through the real UI: BIP-39 generation, libsodium
|
|
// vault encryption and extension storage persistence, for real.
|
|
//
|
|
// Returns the recovery phrase it generated. Tests that assert on a secret
|
|
// need the real value — checking for "some 12 words" would pass against the
|
|
// wrong wallet's phrase, and checking for nothing at all would pass against
|
|
// a screen that shows the phrase it was supposed to hide.
|
|
async function createWallet(page) {
|
|
await page.click("#btn-welcome-add");
|
|
await visible(page, "#view-add-wallet");
|
|
await page.click("#btn-generate-phrase");
|
|
await page.waitForFunction(() => {
|
|
const el = document.getElementById("wallet-mnemonic");
|
|
return el && el.value.trim().split(/\s+/).length >= 12;
|
|
});
|
|
const phrase = (await page.inputValue("#wallet-mnemonic")).trim();
|
|
await page.fill("#add-wallet-password", PASSWORD);
|
|
await page.fill("#add-wallet-password-confirm", PASSWORD);
|
|
await page.click("#btn-add-wallet-confirm");
|
|
await visible(page, "#view-main", 60000);
|
|
return phrase;
|
|
}
|
|
|
|
// Reach the address detail screen of the FIRST address of the first wallet,
|
|
// from wherever the popup restored to. Clicking .address-row does not open
|
|
// it; the [info] button does.
|
|
//
|
|
// .first() rather than a bare selector because the suite adds a second
|
|
// wallet partway through, and every later test would otherwise die in
|
|
// Playwright's strict mode rather than on an assertion.
|
|
async function openAddressDetail(page) {
|
|
const onAddress = await page.isVisible("#view-address");
|
|
if (!onAddress) {
|
|
await visible(page, "#view-main");
|
|
await page.locator("#wallet-list .btn-addr-info").first().click();
|
|
}
|
|
await visible(page, "#view-address");
|
|
}
|
|
|
|
module.exports = {
|
|
PASSWORD,
|
|
createWallet,
|
|
launch,
|
|
openAddressDetail,
|
|
openPopup,
|
|
pageCompilesWasm,
|
|
visible,
|
|
};
|