1 Commits
Author SHA1 Message Date
sneak 7748e49aa8 fix: a request whose origin has no host is refused, not treated as a site (closes #527)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
A page with an opaque origin, such as one its server sandboxes with a
Content-Security-Policy, is reported to the background with the origin
"null", and Chrome reports a local file as "file://". The background took
either as a site, so every such page was one site: a connection remembered
for one covered them all, and the phishing check never matched. The
background now parses the reported origin and, when it has no host, refuses
the request with 4100 like one with no origin, before any method runs.
tests/rpcOrigin.test.js sends both, and README's SiteApproval says what is
refused.

Model: opus-5-5
2026-10-16 02:04:16 +00:00
12 changed files with 47 additions and 528 deletions
+5 -32
View File
@@ -362,14 +362,6 @@ are outside `make check`. Neither opens the popup from the toolbar button: both
load its page in an ordinary tab, so what the toolbar popup itself adds, its load its page in an ordinary tab, so what the toolbar popup itself adds, its
size and its closing when it loses focus, is covered by neither. size and its closing when it loses focus, is covered by neither.
Each run builds its image under a tag of its own, `autistmask-e2e-chrome:` or
`autistmask-e2e-firefox:` followed by twelve random hex digits, so two clones
can run the same suite at once without one build taking the other's image away.
The run removes that image, and its container if one is still running, after the
suite passes or fails, on Ctrl-C, on `kill`, and when the terminal or SSH
session it runs in closes. A run killed outright (`kill -9`) cannot clean up:
its image stays under its tag until removed by hand with `docker image rm`.
### Chrome (`make test-e2e`) ### Chrome (`make test-e2e`)
`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real `make test-e2e` builds `dist/chrome/` and drives the **real popup in a real
@@ -487,9 +479,6 @@ papered over:
forced by clicking Reveal and the settings gear in one page task, which a forced by clicking Reveal and the settings gear in one page task, which a
person cannot do; the case a person can hit, the first decrypt after the popup person cannot do; the case a person can hit, the first decrypt after the popup
opens while libsodium is still loading, is not driven. opens while libsodium is still loading, is not driven.
- Approval windows open at 1280x720 whatever size the extension asks for, so
whether an approval screen fits its window is checked in the Firefox suite
only.
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
@@ -584,9 +573,7 @@ suite), wallet creation through the UI, the Add Token screen, and the dApp round
trips — `eth_requestAccounts`, `personal_sign`, `wallet_switchEthereumChain` trips — `eth_requestAccounts`, `personal_sign`, `wallet_switchEthereumChain`
rejected and then approved, `eth_sendTransaction`, and a closed approval window rejected and then approved, `eth_sendTransaction`, and a closed approval window
rejecting with EIP-1193 4001 — driven through the real content script, rejecting with EIP-1193 4001 — driven through the real content script,
background page and approval windows. The `personal_sign` prompt is also background page and approval windows.
required to fit its window without scrolling sideways, with the extension's
pages at 170% zoom.
The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has
**no npm dependencies at all**: it is built on global `fetch` and **no npm dependencies at all**: it is built on global `fetch` and
@@ -960,16 +947,8 @@ discoverable.
enough that vanilla JS with simple view switching is sufficient. A framework enough that vanilla JS with simple view switching is sufficient. A framework
would add bundle size, build complexity, and attack surface for no benefit at would add bundle size, build complexity, and attack surface for no benefit at
this scale. this scale.
- **396x600 popup**: The page is 396 pixels wide (`body` in - **360x600 popup**: Standard browser extension popup dimensions. The UI is
`src/popup/styles/main.css`), and 600 pixels is as tall as the browser lets designed for this fixed viewport.
the toolbar popup grow. The UI is designed for this fixed viewport. The window
an approval opens in is given the same size. The browser counts the window's
frame in that width, and a screen taller than the window gives some of it to a
vertical scrollbar, so once the approval's screen is up the page widens its
window by however far it would scroll sideways, scaled by the browser's zoom.
Firefox draws some zoom steps a little larger than the zoom it reports, so the
page measures again once the window has grown, and widens it again while it
still falls short.
#### No Layout Shift #### No Layout Shift
@@ -1552,14 +1531,8 @@ view would leave a wallet one click from deletion.
- **Transitions**: - **Transitions**:
- "Import" with a valid entry and a matching password of at least 12 - "Import" with a valid entry and a matching password of at least 12
characters → creates the wallet, clears the navigation stack, and → characters → creates the wallet, clears the navigation stack, and →
**Home**. When no address is active yet, as on the first wallet, the new **Home**. The phrase and xprv modes then scan for further used addresses
wallet's first address becomes the active one. The phrase and xprv modes and report the count as a flash message.
then scan for further used addresses and report the count as a flash
message. When the scan finds two or more used addresses, they replace the
wallet's list, which then holds the first address only if it was used. If
the first address was active and is not in the new list, the new list's
first address becomes the active one and `AUTISTMASK_ACTIVE_CHANGED` is
broadcast.
- "Import" with a missing, short or mismatched password → full-sentence - "Import" with a missing, short or mismatched password → full-sentence
error on the error line, no screen change error on the error line, no screen change
- "Import" with an invalid entry or a duplicate wallet or address → flash - "Import" with an invalid entry or a duplicate wallet or address → flash
+17 -30
View File
@@ -22,29 +22,13 @@ set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
# Each run builds, runs and then removes an image under a tag of its own. IMAGE="$("$SCRIPT_DIR/projectname")-e2e-chrome"
# Under one tag shared by every run, a second clone's build moves the tag
# off this run's image, and the image store can then delete that untagged IIDFILE=""
# image before or while this run uses it. The tag is random rather than
# $$ because CI jobs share one docker daemon, but each job's container
# numbers its processes from scratch.
SUFFIX="$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')"
NAME="$("$SCRIPT_DIR/projectname")-e2e-chrome"
IMAGE="$NAME:$SUFFIX"
# Each docker run below uses this container name: docker run --rm returns
# only once its container is removed, so no run meets the one before it.
CONTAINER="$NAME-$SUFFIX"
cleanup() { cleanup() {
# After a third Ctrl-C docker's client exits whether or not the if [ -n "$IIDFILE" ]; then
# container has stopped, and a container still running keeps the image rm -f "$IIDFILE"
# in use.
if docker container inspect "$CONTAINER" >/dev/null 2>&1; then
docker container rm -f "$CONTAINER" >/dev/null
fi
# No image to remove when the build failed or was interrupted.
if docker image inspect "$IMAGE" >/dev/null 2>&1; then
docker image rm "$IMAGE" >/dev/null
fi fi
} }
@@ -56,16 +40,22 @@ main() {
exit 1 exit 1
fi fi
IIDFILE="$(mktemp)"
trap cleanup EXIT trap cleanup EXIT
# HUP: the terminal or SSH session running the suite closed. trap 'cleanup; exit 130' INT TERM
trap 'cleanup; exit 130' HUP INT TERM
echo "Building the Chrome e2e image (extension included)..." echo "Building the Chrome e2e image (extension included)..."
# --no-cache: the image build runs make build and its checks, and a # --no-cache: the image build runs make build and its checks, and a
# cached layer is a check that did not run. # cached layer is a check that did not run.
docker build --no-cache -t "$IMAGE" -f tests/e2e/Dockerfile . docker build --no-cache --iidfile "$IIDFILE" -t "$IMAGE" \
-f tests/e2e/Dockerfile .
echo "Running e2e suite in the pinned Playwright container..." echo "Running e2e suite in the pinned Playwright container..."
# The image is run by ID, not by tag: where two clones of this repo run
# the suite at once, the other build can move the tag between this
# build and this run, and the suite would then silently test the other
# checkout.
#
# --ipc=host: Chromium's shared-memory needs more than the default # --ipc=host: Chromium's shared-memory needs more than the default
# 64MB /dev/shm or renderers crash. # 64MB /dev/shm or renderers crash.
# HOME=/tmp: the image's root home is not a reliable place for the # HOME=/tmp: the image's root home is not a reliable place for the
@@ -85,12 +75,11 @@ main() {
# probe. The image is pinned by digest, so this can only ever bite # probe. The image is pinned by digest, so this can only ever bite
# on a deliberate bump. # on a deliberate bump.
docker run --rm \ docker run --rm \
--name "$CONTAINER" \
--ipc=host \ --ipc=host \
-e HOME=/tmp \ -e HOME=/tmp \
-e PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 \ -e PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 \
-e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \ -e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \
"$IMAGE" \ "$(cat "$IIDFILE")" \
node tests/e2e/run.js node tests/e2e/run.js
# Where chrome.storage.local lives, and what moves it: two unpacked loads # Where chrome.storage.local lives, and what moves it: two unpacked loads
@@ -104,10 +93,9 @@ main() {
# --host-resolver-rules=MAP * ~NOTFOUND so nothing it does can leave. # --host-resolver-rules=MAP * ~NOTFOUND so nothing it does can leave.
echo "Running the extension-id and storage-partition observations..." echo "Running the extension-id and storage-partition observations..."
docker run --rm \ docker run --rm \
--name "$CONTAINER" \
--ipc=host \ --ipc=host \
-e HOME=/tmp \ -e HOME=/tmp \
"$IMAGE" \ "$(cat "$IIDFILE")" \
node tests/e2e/storagePartition.js node tests/e2e/storagePartition.js
# Approvals left open for seven minutes in minimized windows, past the # Approvals left open for seven minutes in minimized windows, past the
@@ -117,10 +105,9 @@ main() {
# attached to. It reaches nothing but its own loopback server. # attached to. It reaches nothing but its own loopback server.
echo "Running the approvals left open for seven minutes..." echo "Running the approvals left open for seven minutes..."
docker run --rm \ docker run --rm \
--name "$CONTAINER" \
--ipc=host \ --ipc=host \
-e HOME=/tmp \ -e HOME=/tmp \
"$IMAGE" \ "$(cat "$IIDFILE")" \
node tests/e2e/slowApproval.js node tests/e2e/slowApproval.js
} }
+16 -22
View File
@@ -21,23 +21,13 @@ set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
# A tag and a container name of this run's own, as in script/test-e2e, IMAGE="$("$SCRIPT_DIR/projectname")-e2e-firefox"
# which says why.
SUFFIX="$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')" IIDFILE=""
NAME="$("$SCRIPT_DIR/projectname")-e2e-firefox"
IMAGE="$NAME:$SUFFIX"
CONTAINER="$NAME-$SUFFIX"
cleanup() { cleanup() {
# After a third Ctrl-C docker's client exits whether or not the if [ -n "$IIDFILE" ]; then
# container has stopped, and a container still running keeps the image rm -f "$IIDFILE"
# in use.
if docker container inspect "$CONTAINER" >/dev/null 2>&1; then
docker container rm -f "$CONTAINER" >/dev/null
fi
# No image to remove when the build failed or was interrupted.
if docker image inspect "$IMAGE" >/dev/null 2>&1; then
docker image rm "$IMAGE" >/dev/null
fi fi
} }
@@ -49,16 +39,22 @@ main() {
exit 1 exit 1
fi fi
IIDFILE="$(mktemp)"
trap cleanup EXIT trap cleanup EXIT
# HUP: the terminal or SSH session running the suite closed. trap 'cleanup; exit 130' INT TERM
trap 'cleanup; exit 130' HUP INT TERM
echo "Building the pinned Firefox e2e image (extension included)..." echo "Building the pinned Firefox e2e image (extension included)..."
# --no-cache: the image build runs make build and its checks, and a # --no-cache: the image build runs make build and its checks, and a
# cached layer is a check that did not run. # cached layer is a check that did not run.
docker build --no-cache -t "$IMAGE" -f tests/e2e/firefox/Dockerfile . docker build --no-cache --iidfile "$IIDFILE" -t "$IMAGE" \
-f tests/e2e/firefox/Dockerfile .
echo "Running the Firefox e2e suite..." echo "Running the Firefox e2e suite..."
# The image is run by ID, not by tag: where two clones of this repo run
# the suite at once, the other build can move the tag between this
# build and this run, and the suite would then silently test the other
# checkout.
#
# --shm-size=1g: Firefox needs more than the default 64MB /dev/shm. # --shm-size=1g: Firefox needs more than the default 64MB /dev/shm.
# --network none: the suite stubs nothing, so this is what keeps the # --network none: the suite stubs nothing, so this is what keeps the
# run offline and deterministic. The extension swallows its own # run offline and deterministic. The extension swallows its own
@@ -73,11 +69,10 @@ main() {
# "CanCreateUserNamespace() clone() failure: EPERM" on startup here; # "CanCreateUserNamespace() clone() failure: EPERM" on startup here;
# it is cosmetic and headless Firefox runs fine without it. # it is cosmetic and headless Firefox runs fine without it.
docker run --rm \ docker run --rm \
--name "$CONTAINER" \
--shm-size=1g \ --shm-size=1g \
--network none \ --network none \
-e HOME=/tmp \ -e HOME=/tmp \
"$IMAGE" \ "$(cat "$IIDFILE")" \
node tests/e2e/firefox/run.js dist/firefox node tests/e2e/firefox/run.js dist/firefox
# The install/uninstall/re-install property, against the packaged XPI # The install/uninstall/re-install property, against the packaged XPI
@@ -87,11 +82,10 @@ main() {
# and the suite above shares one session throughout. # and the suite above shares one session throughout.
echo "Running the Firefox re-install suite against the packaged XPI..." echo "Running the Firefox re-install suite against the packaged XPI..."
docker run --rm \ docker run --rm \
--name "$CONTAINER" \
--shm-size=1g \ --shm-size=1g \
--network none \ --network none \
-e HOME=/tmp \ -e HOME=/tmp \
"$IMAGE" \ "$(cat "$IIDFILE")" \
node tests/e2e/firefox/reinstall.js node tests/e2e/firefox/reinstall.js
} }
+2 -5
View File
@@ -453,10 +453,7 @@ function releaseApproval(approval) {
// the result on the floor, so a rejection here would be unhandled. // the result on the floor, so a rejection here would be unhandled.
async function openApprovalWindow(id) { async function openApprovalWindow(id) {
const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id); const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id);
// As wide as the page: body in src/popup/styles/main.css. The browser const popupWidth = 360;
// counts the window's frame in this width; the page itself adds back
// whatever the frame takes (src/popup/index.js).
const popupWidth = 396;
const popupHeight = 600; const popupHeight = 600;
let currentWin = null; let currentWin = null;
@@ -476,7 +473,7 @@ async function openApprovalWindow(id) {
// Centred on a browser window only. The last focused window can be // Centred on a browser window only. The last focused window can be
// another approval window still open, and centring on one can give a // another approval window still open, and centring on one can give a
// position the browser refuses ("Bounds must be at least 50% within // position the browser refuses ("Bounds must be at least 50% within
// visible screen space"): headless Chrome reports this 396x600 popup as // visible screen space"): headless Chrome reports this 360x600 popup as
// 1280x720. The request then failed with no window at all. Over a popup, // 1280x720. The request then failed with no window at all. Over a popup,
// the browser picks the position. // the browser picks the position.
if (currentWin && currentWin.type === "normal") { if (currentWin && currentWin.type === "normal") {
+2 -42
View File
@@ -21,12 +21,6 @@ const {
goBack, goBack,
} = require("./views/helpers"); } = require("./views/helpers");
const { applyTheme } = require("./theme"); const { applyTheme } = require("./theme");
const {
tabsGetCurrent,
tabsGetZoom,
windowsGetCurrent,
windowsUpdate,
} = require("../shared/browserApi");
// Renders a view the popup lands on without having navigated to it forward: // Renders a view the popup lands on without having navigated to it forward:
// on restore here, and on Back. Only the views that can be fully re-rendered // on restore here, and on Back. Only the views that can be fully re-rendered
// from persisted state (RESTORABLE_VIEWS, src/shared/restorableViews.js) go // from persisted state (RESTORABLE_VIEWS, src/shared/restorableViews.js) go
@@ -169,40 +163,6 @@ function fallbackView() {
showView("main"); showView("main");
} }
// The background opens the approval window as wide as the page, but the
// browser counts the window's frame in that width, and a screen taller than
// the window takes a vertical scrollbar out of it too. So once the screen is
// up, the window is widened by however far the page would scroll sideways.
// That distance is in page pixels and the window's width in screen pixels:
// at 150% zoom one page pixel is 1.5 screen pixels. The zoom is read for this
// page's own tab, because asked without a tab id Firefox answers for the
// window focused last. Firefox draws some zoom steps a little larger than the
// zoom it reports, 170% among them, so once the page has its new width (its
// resize event) it is measured again, and the window widened again while the
// page still falls short, three times at most. A window that cannot grow sends
// no resize event, and the page stops there. The connection prompt can also
// show in the toolbar popup, whose window is the user's browser window; that
// one is left alone.
async function widenApprovalWindow() {
const page = document.documentElement;
if (page.scrollWidth <= page.clientWidth) return;
const win = await windowsGetCurrent();
if (win.type !== "popup") return;
const tab = await tabsGetCurrent();
const zoom = await tabsGetZoom(tab.id);
let width = win.width;
for (let tries = 0; tries < 3; tries++) {
const sideways = page.scrollWidth - page.clientWidth;
if (sideways <= 0) return;
width += Math.ceil(sideways * zoom);
const resized = new Promise((resolve) =>
window.addEventListener("resize", resolve, { once: true }),
);
await windowsUpdate(win.id, { width });
await resized;
}
}
async function init() { async function init() {
// First, before anything can save: showView() saves on every navigation // First, before anything can save: showView() saves on every navigation
// without awaiting, so a save that fails from here on has somewhere to be // without awaiting, so a save that fails from here on has somewhere to be
@@ -279,8 +239,8 @@ async function init() {
// the run on both harnesses (Playwright `pageerror`, and the Firefox // the run on both harnesses (Playwright `pageerror`, and the Firefox
// driver's console-service drain), so nothing is lost by leaving it // driver's console-service drain), so nothing is lost by leaving it
// on that path. show() puts the approval's own screen up once the // on that path. show() puts the approval's own screen up once the
// background has described it; the window is widened to fit it then. // background has described it.
approval.show(approvalId).then(widenApprovalWindow); approval.show(approvalId);
return; return;
} }
-25
View File
@@ -20,7 +20,6 @@ const {
const { encryptWithPassword } = require("../../shared/vault"); const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { scanForAddresses } = require("../../shared/balances"); const { scanForAddresses } = require("../../shared/balances");
const { notify } = require("../../shared/browserApi");
/** /**
* Check if an address already exists in ANY wallet (hd, xprv, or key). * Check if an address already exists in ANY wallet (hd, xprv, or key).
@@ -195,9 +194,6 @@ async function importMnemonic(ctx) {
}; };
state.wallets.push(wallet); state.wallets.push(wallet);
state.hasWallet = true; state.hasWallet = true;
if (state.activeAddress === null) {
state.activeAddress = firstAddress;
}
await saveState(); await saveState();
clearViewStack(); clearViewStack();
ctx.renderWalletList(); ctx.renderWalletList();
@@ -219,16 +215,8 @@ async function importMnemonic(ctx) {
tokenBalances: [], tokenBalances: [],
})); }));
wallet.nextIndex = scan.nextIndex; wallet.nextIndex = scan.nextIndex;
// The new list leaves out the first address when the scan found it
// unused. If it was the active one, the new list's first takes over,
// and the background is told as when the user picks one on Home.
const activeMoved =
state.activeAddress === firstAddress &&
wallet.addresses[0].address !== firstAddress;
if (activeMoved) state.activeAddress = wallet.addresses[0].address;
await saveState(); await saveState();
ctx.renderWalletList(); ctx.renderWalletList();
if (activeMoved) notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
showFlash("Found " + scan.addresses.length + " addresses."); showFlash("Found " + scan.addresses.length + " addresses.");
} else { } else {
showFlash("Ready.", 1000); showFlash("Ready.", 1000);
@@ -266,9 +254,6 @@ async function importPrivateKey(ctx) {
addresses: [{ address: addr, balance: "0.0000", tokenBalances: [] }], addresses: [{ address: addr, balance: "0.0000", tokenBalances: [] }],
}); });
state.hasWallet = true; state.hasWallet = true;
if (state.activeAddress === null) {
state.activeAddress = addr;
}
await saveState(); await saveState();
clearViewStack(); clearViewStack();
ctx.renderWalletList(); ctx.renderWalletList();
@@ -330,9 +315,6 @@ async function importXprvKey(ctx) {
}; };
state.wallets.push(wallet); state.wallets.push(wallet);
state.hasWallet = true; state.hasWallet = true;
if (state.activeAddress === null) {
state.activeAddress = firstAddress;
}
await saveState(); await saveState();
clearViewStack(); clearViewStack();
ctx.renderWalletList(); ctx.renderWalletList();
@@ -354,15 +336,8 @@ async function importXprvKey(ctx) {
tokenBalances: [], tokenBalances: [],
})); }));
wallet.nextIndex = scan.nextIndex; wallet.nextIndex = scan.nextIndex;
// As for a recovery phrase: an active first address the scan left
// out gives way to the new list's first.
const activeMoved =
state.activeAddress === firstAddress &&
wallet.addresses[0].address !== firstAddress;
if (activeMoved) state.activeAddress = wallet.addresses[0].address;
await saveState(); await saveState();
ctx.renderWalletList(); ctx.renderWalletList();
if (activeMoved) notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
showFlash("Found " + scan.addresses.length + " addresses."); showFlash("Found " + scan.addresses.length + " addresses.");
} else { } else {
showFlash("Ready.", 1000); showFlash("Ready.", 1000);
-36
View File
@@ -243,22 +243,6 @@ function tabsSendMessage(tabId, message) {
return invoke(tabsApi(), "sendMessage", tabId, message); return invoke(tabsApi(), "sendMessage", tabId, message);
} }
/**
* @returns {Promise<Object|undefined>} the tab the calling page is in, or
* undefined for a page not in a tab, such as the toolbar popup.
*/
function tabsGetCurrent() {
return invoke(tabsApi(), "getCurrent");
}
/**
* @param {number} tabId
* @returns {Promise<number>} that tab's zoom: 1.5 at 150%.
*/
function tabsGetZoom(tabId) {
return invoke(tabsApi(), "getZoom", tabId);
}
/** /**
* @param {Object} createData * @param {Object} createData
* @returns {Promise<Object>} the created window. * @returns {Promise<Object>} the created window.
@@ -274,22 +258,6 @@ function windowsGetLastFocused() {
return invoke(windowsApi(), "getLastFocused"); return invoke(windowsApi(), "getLastFocused");
} }
/**
* @returns {Promise<Object>} the window the calling page is in.
*/
function windowsGetCurrent() {
return invoke(windowsApi(), "getCurrent");
}
/**
* @param {number} windowId
* @param {Object} updateInfo
* @returns {Promise<Object>} the updated window.
*/
function windowsUpdate(windowId, updateInfo) {
return invoke(windowsApi(), "update", windowId, updateInfo);
}
/** /**
* @param {number} windowId * @param {number} windowId
* @returns {Promise<void>} * @returns {Promise<void>}
@@ -312,14 +280,10 @@ module.exports = {
storageRemove, storageRemove,
storageSet, storageSet,
tabsApi, tabsApi,
tabsGetCurrent,
tabsGetZoom,
tabsQuery, tabsQuery,
tabsSendMessage, tabsSendMessage,
windowsApi, windowsApi,
windowsCreate, windowsCreate,
windowsGetCurrent,
windowsGetLastFocused, windowsGetLastFocused,
windowsRemove, windowsRemove,
windowsUpdate,
}; };
-129
View File
@@ -45,132 +45,3 @@ test("the approval window shows no screen until the background describes the app
expect(env.visibleViews()).toEqual(["approve-network"]); expect(env.visibleViews()).toEqual(["approve-network"]);
expect(env.pageErrors).toEqual([]); expect(env.pageErrors).toEqual([]);
}); });
// The background opens the approval window as wide as the page, but the
// browser counts the window's frame in that width, and a screen taller than
// the window takes a vertical scrollbar out of it too
// (https://git.eeqj.de/sneak/AutistMask/issues/518). The browser suites run
// without frames, and only the Firefox suite's sign prompt at a zoom other
// than 100%, so the widening is checked here too.
//
// A window 396 screen pixels wide whose page is drawn at `drawn` screen pixels
// to the page pixel. Widening it gives the page its new width, less the 15
// screen pixels of the scrollbar the approval screen has, and then calls the
// page's resize listeners, each of which asked to be called once.
function windowsStub(type, drawn) {
const updates = [];
return {
updates,
getCurrent: (done) => done({ id: 7, type, width: 396 }),
update: (id, info, done) => {
updates.push({ id, info });
done({});
globalThis.document.documentElement.clientWidth = Math.floor(
(info.width - 15) / drawn,
);
const resized = globalThis.window.listeners.resize || [];
globalThis.window.listeners.resize = [];
for (const fn of resized) fn();
},
};
}
// The approval page's own tab is at `zoom`. Asked without a tab id, Firefox
// answers for the window focused last, here a site's tab at 100%.
function tabsStub(zoom) {
return {
getCurrent: (done) => done({ id: 11 }),
getZoom: (...args) => {
const done = args.pop();
done(args[0] === 11 ? zoom : 1);
},
};
}
// An approval window at `zoom`, `clientWidth` page pixels wide before its
// screen is up, whose background holds its answer until the test gives it.
async function bootZoomedApprovalWindow(windows, zoom, clientWidth) {
let answer = null;
const env = await bootPopup(unversionedValidProfile(), {
search: "?approval=approval-1",
runtime: {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (msg.type === "AUTISTMASK_GET_APPROVAL") answer = reply;
},
},
windows,
tabs: tabsStub(zoom),
documentElement: { scrollWidth: 396, clientWidth },
});
return { env, answer };
}
test("at 150% zoom the approval window widens by what its screen lacks, scrollbar included", async () => {
const windows = windowsStub("popup", 1.5);
// At 150% a window 396 screen pixels wide shows 264 page pixels.
const { env, answer } = await bootZoomedApprovalWindow(windows, 1.5, 264);
// Not before there is a screen to fit.
expect(windows.updates).toEqual([]);
// The screen is taller than the window, so once it is up a vertical
// scrollbar takes 15 screen pixels, 10 page pixels at 150%.
env.document.documentElement.clientWidth = 254;
answer({
type: "network",
origin: "https://dapp.example",
currentNetworkId: "mainnet",
requestedNetworkId: "sepolia",
isPhishingDomain: false,
});
await settle();
// 396 - 254 = 142 page pixels short, which at 150% is 213 screen pixels.
// At the 100% of the site's tab it would be 142, and the screen would
// still not fit.
expect(env.visibleViews()).toEqual(["approve-network"]);
expect(windows.updates).toEqual([{ id: 7, info: { width: 609 } }]);
expect(env.document.documentElement.clientWidth).toBe(396);
expect(env.pageErrors).toEqual([]);
});
test("at 170% the approval window is widened again while its screen still falls short", async () => {
// Firefox reports 170% as 1.7 but draws a page pixel about 1.714 screen
// pixels wide, so a window 396 wide shows 231 page pixels.
const windows = windowsStub("popup", 1.714);
const { env, answer } = await bootZoomedApprovalWindow(windows, 1.7, 231);
// Less the scrollbar once the screen is up.
env.document.documentElement.clientWidth = 222;
answer({
type: "network",
origin: "https://dapp.example",
currentNetworkId: "mainnet",
requestedNetworkId: "sepolia",
isPhishingDomain: false,
});
await settle();
// 174 page pixels short, so 296 screen pixels wider at 1.7. That shows 394
// page pixels, still 2 short, so 4 screen pixels wider again.
expect(windows.updates).toEqual([
{ id: 7, info: { width: 692 } },
{ id: 7, info: { width: 696 } },
]);
const page = env.document.documentElement;
expect(page.scrollWidth).toBeLessThanOrEqual(page.clientWidth);
expect(env.pageErrors).toEqual([]);
});
test("a connection prompt in the toolbar popup leaves the browser window alone", async () => {
const windows = windowsStub("normal", 1.5);
const { env, answer } = await bootZoomedApprovalWindow(windows, 1.5, 264);
answer({ origin: "https://dapp.example", isPhishingDomain: false });
await settle();
expect(env.visibleViews()).toEqual(["approve-site"]);
expect(windows.updates).toEqual([]);
expect(env.pageErrors).toEqual([]);
});
+3 -3
View File
@@ -2930,7 +2930,7 @@ describe("where an approval window opens", () => {
await settle(); await settle();
expect(bg.created).toHaveLength(1); expect(bg.created).toHaveLength(1);
expect(bg.created[0]).toMatchObject({ left: 442, top: 60 }); expect(bg.created[0]).toMatchObject({ left: 460, top: 60 });
}); });
test("not centred on an approval window the user was last in", async () => { test("not centred on an approval window the user was last in", async () => {
@@ -2947,7 +2947,7 @@ describe("where an approval window opens", () => {
bg.requestSign(); bg.requestSign();
await settle(); await settle();
// Centred, it would be at left 882, the position the browser refused. // Centred, it would be at left 900, the position the browser refused.
expect(bg.created).toHaveLength(1); expect(bg.created).toHaveLength(1);
expect(bg.created[0].left).toBeUndefined(); expect(bg.created[0].left).toBeUndefined();
expect(bg.created[0].top).toBeUndefined(); expect(bg.created[0].top).toBeUndefined();
@@ -2969,7 +2969,7 @@ describe("where an approval window opens", () => {
await settle(); await settle();
expect(bg.created).toHaveLength(2); expect(bg.created).toHaveLength(2);
expect(bg.created[0]).toMatchObject({ left: 1502, top: 750 }); expect(bg.created[0]).toMatchObject({ left: 1520, top: 750 });
expect(bg.created[1].left).toBeUndefined(); expect(bg.created[1].left).toBeUndefined();
expect(bg.created[1].top).toBeUndefined(); expect(bg.created[1].top).toBeUndefined();
-35
View File
@@ -394,26 +394,6 @@ async function waitForApprovalWindow(d, timeout = 30000) {
} }
} }
// Firefox keeps one zoom per site, so a zoom set on the popup's tab holds for
// every page of the extension, the approval windows included.
async function setExtensionZoom(env, zoom) {
const d = env.driver;
await d.switchToWindow(env.popupWindow);
const outcome = await d.executeAsync(
`const done = arguments[arguments.length - 1];
const zoom = arguments[0];
browser.tabs
.getCurrent()
.then((tab) => browser.tabs.setZoom(tab.id, zoom))
.then(() => done({}), (e) => done({ error: String(e) }));`,
[zoom],
);
assert(
outcome && !outcome.error,
"could not set the extension's zoom: " + (outcome && outcome.error),
);
}
function startRequest(d, key, method, params) { function startRequest(d, key, method, params) {
return d.execute( return d.execute(
"window.__dapp.start(arguments[0], arguments[1], arguments[2]);" + "window.__dapp.start(arguments[0], arguments[1], arguments[2]);" +
@@ -653,14 +633,6 @@ step(
"personal_sign returns a signature that recovers to the address", "personal_sign returns a signature that recovers to the address",
async (env) => { async (env) => {
const d = env.driver; const d = env.driver;
// The window is opened as wide as the page, and widened once the
// screen is up if it still scrolls sideways
// (https://git.eeqj.de/sneak/AutistMask/issues/518). This suite is
// where that is checked: the Chrome suite's approval windows open at
// 1280x720 whatever width the extension asks for. At 170%, one of
// Firefox's zoom steps, the page is drawn a little larger than the
// 1.7 Firefox reports, so a single widening leaves it a pixel short.
await setExtensionZoom(env, 1.7);
await d.switchToWindow(env.dappWindow); await d.switchToWindow(env.dappWindow);
await startRequest(d, "sign", "personal_sign", [SIGN_HEX, env.address]); await startRequest(d, "sign", "personal_sign", [SIGN_HEX, env.address]);
@@ -668,12 +640,6 @@ step(
await d.switchToWindow(popup); await d.switchToWindow(popup);
await d.waitVisible("#view-approve-sign"); await d.waitVisible("#view-approve-sign");
await d.waitFor(
"the sign prompt to fit its window without scrolling sideways",
"return document.documentElement.scrollWidth <=" +
" document.documentElement.clientWidth;",
);
const screen = await d.execute( const screen = await d.execute(
`return { `return {
origin: document.getElementById("approve-sign-origin").textContent, origin: document.getElementById("approve-sign-origin").textContent,
@@ -708,7 +674,6 @@ step(
await d.switchToWindow(env.dappWindow); await d.switchToWindow(env.dappWindow);
const outcome = await settleRequest(d, "sign"); const outcome = await settleRequest(d, "sign");
await setExtensionZoom(env, 1);
assert( assert(
outcome.settled === "resolved", outcome.settled === "resolved",
"personal_sign did not resolve: " + JSON.stringify(outcome), "personal_sign did not resolve: " + JSON.stringify(outcome),
-141
View File
@@ -1,141 +0,0 @@
// Adding the first wallet left no address active until the popup was reopened,
// so Send and Receive on Home only said "No active address selected."
// (https://git.eeqj.de/sneak/AutistMask/issues/512). Each test adds a wallet
// through one of the three import tabs and checks which address is active by
// opening Receive from Home in the same popup.
const { HDNodeWallet, Mnemonic, Wallet } = require("ethers");
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot");
const PHRASE =
"abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about";
const PRIVATE_KEY = "0x" + "11".repeat(32);
// The master key of PHRASE, so its first address is the phrase's.
const XPRV = HDNodeWallet.fromSeed(
Mnemonic.fromPhrase(PHRASE).computeSeed(),
).extendedKey;
const PASSWORD = "correct horse battery staple";
const PHRASE_ADDRESS = HDNodeWallet.fromPhrase(PHRASE).address;
// Addresses 1 and 2 of PHRASE used, address 0 not: a scan that answers these
// replaces the wallet's list with one that leaves out its first address.
const account = HDNodeWallet.fromPhrase(PHRASE, "", "m/44'/60'/0'/0");
const used = [1, 2].map((index) => ({
address: account.deriveChild(index).address,
index,
}));
const TABS = [
{
tab: "tab-mnemonic",
field: "wallet-mnemonic",
secret: PHRASE,
address: PHRASE_ADDRESS,
},
{
tab: "tab-privkey",
field: "import-private-key",
secret: PRIVATE_KEY,
address: new Wallet(PRIVATE_KEY).address,
},
{
tab: "tab-xprv",
field: "import-xprv-key",
secret: XPRV,
address: PHRASE_ADDRESS,
},
];
// Fills in the open AddWallet screen and presses "Import".
async function importOn(page, { tab, field, secret }) {
await page.click(tab);
page.node(field).value = secret;
page.node("add-wallet-password").value = PASSWORD;
page.node("add-wallet-password-confirm").value = PASSWORD;
await page.click("btn-add-wallet-confirm");
}
afterEach(() => {
cleanupPopup();
});
describe("the first wallet's first address is active right away", () => {
test.each(TABS)("added from $tab, Receive opens on it", async (entry) => {
const page = await bootPopup(undefined);
expect(page.pageErrors).toEqual([]);
await page.click("btn-welcome-add");
await importOn(page, entry);
expect(page.visibleViews()).toEqual(["main"]);
await page.click("btn-main-receive");
expect(page.visibleViews()).toEqual(["receive"]);
expect(page.node("receive-address-block").dataset.full).toBe(
entry.address,
);
});
});
describe("a scan that leaves out the first address", () => {
// The key tab runs no scan.
test.each(TABS.filter((t) => t.tab !== "tab-privkey"))(
"added from $tab, the scan's first address is active",
async (entry) => {
const page = await bootPopup(undefined);
const { scanForAddresses } = require("../src/shared/balances");
scanForAddresses.mockResolvedValueOnce({
addresses: used,
nextIndex: 3,
});
await page.click("btn-welcome-add");
await importOn(page, entry);
expect(page.storage.read("autistmask").activeAddress).toBe(
used[0].address,
);
expect(globalThis.chrome.runtime.sendMessage).toHaveBeenCalledWith({
type: "AUTISTMASK_ACTIVE_CHANGED",
});
await page.click("btn-main-receive");
expect(page.node("receive-address-block").dataset.full).toBe(
used[0].address,
);
},
);
});
describe("adding a wallet while an address is active", () => {
test.each(TABS)(
"added from $tab, the active address is unchanged",
async (entry) => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
// The phrase and xprv scans leave out the new wallet's first
// address, which was never active here. The key tab runs no scan.
const { scanForAddresses } = require("../src/shared/balances");
scanForAddresses.mockResolvedValueOnce({
addresses: used,
nextIndex: 3,
});
await page.click("btn-add-wallet-bottom");
await importOn(page, entry);
expect(page.visibleViews()).toEqual(["main"]);
expect(page.storage.read("autistmask").activeAddress).toBe(ADDRESS);
expect(
globalThis.chrome.runtime.sendMessage,
).not.toHaveBeenCalledWith({ type: "AUTISTMASK_ACTIVE_CHANGED" });
await page.click("btn-main-receive");
expect(page.node("receive-address-block").dataset.full).toBe(
ADDRESS,
);
},
);
});
+2 -28
View File
@@ -242,13 +242,6 @@ async function settle() {
* "?approval=" and an id for the popup opened as an approval window. * "?approval=" and an id for the popup opened as an approval window.
* @param {object} [options.runtime] members of chrome.runtime that replace the * @param {object} [options.runtime] members of chrome.runtime that replace the
* stub's own, for a test that has to answer the background's messages. * stub's own, for a test that has to answer the background's messages.
* @param {object} [options.windows] chrome.windows, for a test of the approval
* window resizing itself.
* @param {object} [options.tabs] chrome.tabs, for the same test: the page's
* tab and its zoom.
* @param {object} [options.documentElement] members of document.documentElement
* that replace the stub's own: scrollWidth and clientWidth, which are equal
* unless a test says otherwise, so the page does not scroll sideways.
* @returns {Promise<object>} handles onto the booted page. * @returns {Promise<object>} handles onto the booted page.
*/ */
async function bootPopup(stored, options) { async function bootPopup(stored, options) {
@@ -265,17 +258,12 @@ async function bootPopup(stored, options) {
formatAddressTotal: () => "", formatAddressTotal: () => "",
getAddressValue: () => ({ usd: null, partial: false }), getAddressValue: () => ({ usd: null, partial: false }),
})); }));
// scanForAddresses() answers in the real one's shape, a scan that found
// no used address: AddWallet reads `addresses` from it.
jest.doMock("../../src/shared/balances", () => ({ jest.doMock("../../src/shared/balances", () => ({
fetchTokenBalances: jest.fn(async () => []), fetchTokenBalances: jest.fn(async () => []),
refreshBalances: jest.fn(async () => {}), refreshBalances: jest.fn(async () => {}),
lookupTokenInfo: jest.fn(async () => null), lookupTokenInfo: jest.fn(async () => null),
getProvider: () => ({}), getProvider: () => ({}),
scanForAddresses: jest.fn(async () => ({ scanForAddresses: jest.fn(async () => []),
addresses: [],
nextIndex: 1,
})),
})); }));
// filterTransactions() answers in the real one's shape: Home, // filterTransactions() answers in the real one's shape: Home,
// AddressDetail and AddressToken read both fields, and a bare list makes // AddressDetail and AddressToken read both fields, and a bare list makes
@@ -289,13 +277,7 @@ async function bootPopup(stored, options) {
(options && options.storage) || (options && options.storage) ||
makeStorageStub(stored === undefined ? {} : { autistmask: stored }); makeStorageStub(stored === undefined ? {} : { autistmask: stored });
const document = makeDocument(POPUP_HTML); const document = makeDocument(POPUP_HTML);
Object.assign(
document.documentElement,
{ scrollWidth: 396, clientWidth: 396 },
options && options.documentElement,
);
const reloads = []; const reloads = [];
const windowListeners = {};
globalThis.chrome = { globalThis.chrome = {
storage: { local: storage.local }, storage: { local: storage.local },
@@ -305,8 +287,6 @@ async function bootPopup(stored, options) {
onMessage: { addListener: () => {} }, onMessage: { addListener: () => {} },
...(options && options.runtime), ...(options && options.runtime),
}, },
windows: options && options.windows,
tabs: options && options.tabs,
}; };
globalThis.document = document; globalThis.document = document;
globalThis.window = { globalThis.window = {
@@ -320,13 +300,7 @@ async function bootPopup(stored, options) {
addEventListener: () => {}, addEventListener: () => {},
removeEventListener: () => {}, removeEventListener: () => {},
}), }),
// Recorded, as the document's are, so a test that widens the approval addEventListener: () => {},
// window can call the page's resize listeners.
listeners: windowListeners,
addEventListener: (name, fn) => {
windowListeners[name] = windowListeners[name] || [];
windowListeners[name].push(fn);
},
}; };
// The ten-second refresh init() starts, and a transaction wait's timers, // The ten-second refresh init() starts, and a transaction wait's timers,
// would outlive the test. So every interval is recorded rather than // would outlive the test. So every interval is recorded rather than