Some checks failed
check / check (push) Has been cancelled
A user who created a wallet in AutistMask and did not write the phrase
down had no way to retrieve it. Adds a "Show recovery phrase" action on
the wallet row in Settings, next to the per-wallet actions that already
live there, mirroring the per-address private key export in structure,
password gate and warning treatment.
The screen displays the secret that owns every address in the wallet, so:
- Only HD wallets are offered it. walletHasRecoveryPhrase() is an
allowlist on type "hd", so the key and xprv types — which have no
phrase at all — are excluded, as is any type added later.
- Nothing is decrypted and nothing enters the page until
decryptWithPassword accepts the password. A wrong password produces a
full-sentence error and leaves the value node empty.
- Leaving the screen wipes it by any route, not just "Back": views that
hold a secret register a cleanup with showView() via onViewLeave(),
which also covers the settings gear.
- A decrypt still in flight when the screen is left is discarded rather
than written. reveal() captures a generation counter that every
clear() bumps and refuses to touch the DOM if it has moved: without
that check the write lands after the wipe, and nothing is scheduled to
wipe again, so the phrase stays in the hidden screen for the life of
the popup. crypto_pwhash is synchronous, so the reachable window is a
still-pending sodium.ready on the first vault use of a page load.
- The phrase is never assigned to state, so it cannot be persisted, and
the view is not in RESTORABLE_VIEWS — reopening the popup lands on
Home. That set moves to src/popup/restorableViews.js so the exclusion
can be asserted directly; the popup entry point cannot be required
outside a browser.
- The phrase cannot reach the logger: the view does not import
src/shared/log.js, and the failed-decrypt path reports a fixed
sentence rather than the caught error.
show() also pushes the navigation stack itself, because it can return
without navigating when the wallet has no phrase; pushing in the Settings
click handler left an entry no screen transition matched.
Tests: unit coverage for the type gate, the RESTORABLE_VIEWS exclusion
and the absence of any logger path; the DOM behaviour is driven against
the real popup in the e2e suite, which is where this repo tests views —
including a probe that leaves the screen while the decrypt is in flight
by dispatching both clicks in one page task, since a human cannot
interleave them once libsodium's wasm is warm.
527 lines
20 KiB
JavaScript
527 lines
20 KiB
JavaScript
// End-to-end suite entrypoint. Run via script/test-e2e (which builds
|
|
// dist/chrome/ and starts the pinned container); running it directly
|
|
// requires a Chromium that playwright-core can find.
|
|
//
|
|
// A plain runner rather than jest on purpose: jest's default testMatch
|
|
// would pull these files into script/test, and browser tests do not fit
|
|
// inside the 20-second cap REPO_POLICIES.md puts on make test. Nothing
|
|
// here is named *.test.js for the same reason.
|
|
|
|
"use strict";
|
|
|
|
const {
|
|
PASSWORD,
|
|
createWallet,
|
|
launch,
|
|
openAddressDetail,
|
|
openPopup,
|
|
visible,
|
|
} = require("./harness");
|
|
const { STUB_TOKEN, STUB_TX_HASH } = require("./network");
|
|
|
|
const TEST_TIMEOUT_MS = 120000;
|
|
|
|
// How long to keep collecting after the final test returns; see the
|
|
// trailing drain in main().
|
|
const TRAILING_WATCH_MS = 1500;
|
|
|
|
const tests = [];
|
|
|
|
function test(name, fn) {
|
|
tests.push({ name, fn });
|
|
}
|
|
|
|
function assert(cond, message) {
|
|
if (!cond) throw new Error(message);
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function withTimeout(promise, name) {
|
|
let timer;
|
|
const timeout = new Promise((_, reject) => {
|
|
timer = setTimeout(
|
|
() =>
|
|
reject(new Error("timed out after " + TEST_TIMEOUT_MS + "ms")),
|
|
TEST_TIMEOUT_MS,
|
|
);
|
|
});
|
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
}
|
|
|
|
// ----------------------------------------------------------------- tests
|
|
|
|
test("popup loads and reaches the welcome view", async (env) => {
|
|
env.page = await openPopup(env.ctx, env.popupUrl);
|
|
await visible(env.page, "#view-welcome");
|
|
const title = await env.page.title();
|
|
assert(title === "AutistMask", "unexpected popup title: " + title);
|
|
});
|
|
|
|
test("wallet creation through the UI reaches the main view", async (env) => {
|
|
env.phrase = await createWallet(env.page);
|
|
assert(
|
|
env.phrase.split(/\s+/).length >= 12,
|
|
"wallet creation did not yield a recovery phrase",
|
|
);
|
|
const addrCount = await env.page
|
|
.locator("#wallet-list .btn-addr-info")
|
|
.count();
|
|
assert(addrCount > 0, "no addresses rendered in the wallet list");
|
|
});
|
|
|
|
test("add token screen opens from address detail (#150)", async (env) => {
|
|
await openAddressDetail(env.page);
|
|
await env.page.click("#btn-add-token");
|
|
await visible(env.page, "#view-add-token");
|
|
const quickPicks = await env.page
|
|
.locator("#common-token-list .common-token")
|
|
.count();
|
|
assert(quickPicks > 0, "no common-token quick-pick buttons rendered");
|
|
});
|
|
|
|
test("transaction detail renders an ERC-20 transfer (#151)", async (env) => {
|
|
// Serve the stubbed token transfer from here on, then reload so the
|
|
// address detail screen refetches its transaction list.
|
|
env.routeOpts.seedTokenTransfer = true;
|
|
await env.page.reload();
|
|
await openAddressDetail(env.page);
|
|
|
|
await visible(env.page, "#tx-list .tx-row");
|
|
const rowText = await env.page
|
|
.locator("#tx-list .tx-row")
|
|
.first()
|
|
.innerText();
|
|
assert(
|
|
rowText.includes(STUB_TOKEN.symbol),
|
|
"token transfer row missing symbol " +
|
|
STUB_TOKEN.symbol +
|
|
", got: " +
|
|
JSON.stringify(rowText),
|
|
);
|
|
|
|
await env.page.locator("#tx-list .tx-row").first().click();
|
|
await visible(env.page, "#view-transaction");
|
|
|
|
const hash = await env.page.locator("#tx-detail-hash").innerText();
|
|
assert(
|
|
hash.includes(STUB_TX_HASH),
|
|
"transaction detail shows the wrong hash: " + hash,
|
|
);
|
|
|
|
// The token contract row is the field that crashes when
|
|
// addressDotHtml is not imported: it renders only for transfers with
|
|
// a contractAddress, which is every ERC-20 transfer.
|
|
await visible(env.page, "#tx-detail-token-contract-section");
|
|
const contract = env.page.locator("#tx-detail-token-contract");
|
|
const contractText = await contract.innerText();
|
|
assert(
|
|
contractText.toLowerCase().includes(STUB_TOKEN.address),
|
|
"token contract row missing the contract address, got: " +
|
|
JSON.stringify(contractText),
|
|
);
|
|
const dots = await contract.locator('span[style*="border-radius"]').count();
|
|
assert(dots > 0, "token contract row rendered without its colour dot");
|
|
});
|
|
|
|
// -------------------------------------------- recovery phrase (#161)
|
|
|
|
// The gear toggles, so pressing it while Settings is already up leaves it.
|
|
async function openSettings(page) {
|
|
if (!(await page.isVisible("#view-settings"))) {
|
|
await page.click("#btn-settings");
|
|
}
|
|
await visible(page, "#view-settings");
|
|
}
|
|
|
|
// Everything the recovery phrase screen is holding, read straight out of
|
|
// the DOM whether or not that screen is the one on top. Reading it while it
|
|
// is hidden is the point: "cleared on leave" means the node is empty, not
|
|
// merely off-screen.
|
|
async function phraseScreenState(page) {
|
|
return page.evaluate(() => ({
|
|
value: document.getElementById("show-phrase-value").textContent,
|
|
error: document.getElementById("show-phrase-flash").textContent,
|
|
html: document.getElementById("view-show-phrase").innerHTML,
|
|
resultHidden: document
|
|
.getElementById("show-phrase-result")
|
|
.classList.contains("hidden"),
|
|
viewHidden: document
|
|
.getElementById("view-show-phrase")
|
|
.classList.contains("hidden"),
|
|
}));
|
|
}
|
|
|
|
async function openPhraseScreen(page) {
|
|
await openSettings(page);
|
|
await page.click("#settings-wallet-list .btn-show-phrase");
|
|
await visible(page, "#view-show-phrase");
|
|
}
|
|
|
|
async function revealPhrase(page) {
|
|
await page.fill("#show-phrase-password", PASSWORD);
|
|
await page.click("#btn-show-phrase-reveal");
|
|
await visible(page, "#show-phrase-result", 60000);
|
|
}
|
|
|
|
function assertWiped(st, phrase, where) {
|
|
assert(st.value === "", "phrase still in the DOM " + where);
|
|
assert(st.resultHidden, "result section still shown " + where);
|
|
assert(
|
|
!st.html.includes(phrase),
|
|
"the recovery phrase is still somewhere in the screen markup " + where,
|
|
);
|
|
}
|
|
|
|
test("only an HD wallet is offered the recovery phrase action (#161)", async (env) => {
|
|
await openSettings(env.page);
|
|
const offered = await env.page
|
|
.locator("#settings-wallet-list .btn-show-phrase")
|
|
.count();
|
|
const wallets = await env.page
|
|
.locator("#settings-wallet-list .btn-delete-wallet")
|
|
.count();
|
|
assert(wallets === 1, "expected exactly one wallet row, got " + wallets);
|
|
assert(
|
|
offered === 1,
|
|
"the HD wallet was not offered the recovery phrase action",
|
|
);
|
|
});
|
|
|
|
// The other half of the gate, against the real UI: a wallet holding a bare
|
|
// private key has no phrase to show, so no row of it may offer the action.
|
|
// The key is generated here rather than committed — the repo holds no
|
|
// private keys, test ones included.
|
|
test("a key wallet is not offered the recovery phrase action (#161)", async (env) => {
|
|
const { Wallet } = require("ethers");
|
|
|
|
await openSettings(env.page);
|
|
await env.page.click("#btn-main-add-wallet");
|
|
await visible(env.page, "#view-add-wallet");
|
|
await env.page.click("#tab-privkey");
|
|
await env.page.fill(
|
|
"#import-private-key",
|
|
Wallet.createRandom().privateKey,
|
|
);
|
|
await env.page.fill("#add-wallet-password", PASSWORD);
|
|
await env.page.fill("#add-wallet-password-confirm", PASSWORD);
|
|
await env.page.click("#btn-add-wallet-confirm");
|
|
await visible(env.page, "#view-main", 60000);
|
|
|
|
await openSettings(env.page);
|
|
const wallets = await env.page
|
|
.locator("#settings-wallet-list .btn-delete-wallet")
|
|
.count();
|
|
const offered = await env.page
|
|
.locator("#settings-wallet-list .btn-show-phrase")
|
|
.count();
|
|
assert(wallets === 2, "expected two wallet rows, got " + wallets);
|
|
assert(
|
|
offered === 1,
|
|
"the key wallet was offered the recovery phrase action",
|
|
);
|
|
});
|
|
|
|
test("the recovery phrase screen holds nothing before the password (#161)", async (env) => {
|
|
await openPhraseScreen(env.page);
|
|
const st = await phraseScreenState(env.page);
|
|
assertWiped(st, env.phrase, "before any password was entered");
|
|
const passwordShown = await env.page.isVisible(
|
|
"#show-phrase-password-section",
|
|
);
|
|
assert(passwordShown, "the password prompt is not shown");
|
|
});
|
|
|
|
test("a wrong password reveals nothing (#161)", async (env) => {
|
|
await env.page.fill("#show-phrase-password", "not-the-password");
|
|
await env.page.click("#btn-show-phrase-reveal");
|
|
await env.page.waitForFunction(
|
|
() =>
|
|
document.getElementById("show-phrase-flash").textContent.length > 0,
|
|
null,
|
|
{ timeout: 60000 },
|
|
);
|
|
|
|
const st = await phraseScreenState(env.page);
|
|
assertWiped(st, env.phrase, "after a wrong password");
|
|
assert(
|
|
/^[A-Z].*\.$/.test(st.error.trim()),
|
|
"the wrong-password error is not a full sentence: " +
|
|
JSON.stringify(st.error),
|
|
);
|
|
});
|
|
|
|
test("the correct password reveals the full phrase, and nothing logs it (#161)", async (env) => {
|
|
const console_ = [];
|
|
const listener = (msg) => console_.push(msg.text());
|
|
env.page.on("console", listener);
|
|
try {
|
|
await revealPhrase(env.page);
|
|
|
|
const st = await phraseScreenState(env.page);
|
|
assert(
|
|
st.value === env.phrase,
|
|
"the displayed phrase is not the wallet's phrase, verbatim",
|
|
);
|
|
const promptShown = await env.page.isVisible(
|
|
"#show-phrase-password-section",
|
|
);
|
|
assert(!promptShown, "the password prompt is still shown after unlock");
|
|
|
|
// Full Identifiers Policy: shown whole, and copyable.
|
|
const title = await env.page.getAttribute(
|
|
"#show-phrase-value",
|
|
"title",
|
|
);
|
|
assert(title === "Click to copy", "the phrase is not click-to-copy");
|
|
|
|
const leaked = console_.filter((line) => line.includes(env.phrase));
|
|
assert(
|
|
leaked.length === 0,
|
|
"the recovery phrase reached the console: " +
|
|
JSON.stringify(leaked),
|
|
);
|
|
} finally {
|
|
env.page.off("console", listener);
|
|
}
|
|
});
|
|
|
|
test('"Back" wipes the revealed phrase (#161)', async (env) => {
|
|
await env.page.click("#btn-show-phrase-back");
|
|
await visible(env.page, "#view-settings");
|
|
const st = await phraseScreenState(env.page);
|
|
assert(st.viewHidden, "the recovery phrase screen is still on top");
|
|
assertWiped(st, env.phrase, "after Back");
|
|
});
|
|
|
|
// The settings gear leaves the screen without touching its Back button. A
|
|
// clear wired only to Back would pass the test above and leak here.
|
|
test("leaving by the settings gear wipes it too (#161)", async (env) => {
|
|
await openPhraseScreen(env.page);
|
|
await revealPhrase(env.page);
|
|
await env.page.click("#btn-settings");
|
|
await visible(env.page, "#view-settings");
|
|
const st = await phraseScreenState(env.page);
|
|
assertWiped(st, env.phrase, "after leaving via the settings gear");
|
|
});
|
|
|
|
// The same leave, but taken while the decrypt is still running. Both
|
|
// clicks are dispatched inside one page task on purpose: "Reveal" runs its
|
|
// handler up to the await, the gear then runs the leave — and the wipe with
|
|
// it — to completion, and the decrypt's continuation resumes afterwards.
|
|
// Without a liveness check that continuation writes the phrase into the
|
|
// hidden screen after the wipe, and nothing is left to wipe it again.
|
|
//
|
|
// A human cannot produce this interleaving by hand once libsodium's wasm is
|
|
// warm, because crypto_pwhash is synchronous and the only suspension point
|
|
// is a microtask; the window a user can actually hit is a still-pending
|
|
// sodium.ready on the first vault use of a page load. Forcing it here is
|
|
// the only way to test the guard deterministically.
|
|
test("leaving while the decrypt is in flight reveals nothing (#161)", async (env) => {
|
|
await openPhraseScreen(env.page);
|
|
await env.page.fill("#show-phrase-password", PASSWORD);
|
|
await env.page.evaluate(() => {
|
|
document.getElementById("btn-show-phrase-reveal").click();
|
|
document.getElementById("btn-settings").click();
|
|
});
|
|
await visible(env.page, "#view-settings");
|
|
|
|
// The Reveal button is disabled for exactly the duration of the
|
|
// decrypt and re-enabled in the same continuation that would have
|
|
// written the phrase, so waiting for it to come back is a precise
|
|
// "the decrypt has settled and its handler has finished" signal
|
|
// rather than a guess at a duration.
|
|
await env.page.waitForFunction(
|
|
() => !document.getElementById("btn-show-phrase-reveal").disabled,
|
|
null,
|
|
{ timeout: 60000 },
|
|
);
|
|
await sleep(2000);
|
|
|
|
const st = await phraseScreenState(env.page);
|
|
// Printed on every run, pass or fail: "the phrase is not there" is
|
|
// worth more as a measurement than as a silent assertion, and the
|
|
// same line read from a build without the guard is what this test
|
|
// exists to prevent.
|
|
console.log(
|
|
"# probe: len=" +
|
|
st.value.length +
|
|
" equalsPhrase=" +
|
|
(st.value === env.phrase) +
|
|
" resultHidden=" +
|
|
st.resultHidden +
|
|
" viewHidden=" +
|
|
st.viewHidden,
|
|
);
|
|
assert(st.viewHidden, "the recovery phrase screen is still on top");
|
|
assertWiped(st, env.phrase, "after leaving mid-decrypt");
|
|
});
|
|
|
|
// Closing and reopening the page rather than reloading it: that is what
|
|
// the toolbar popup actually does, and the persisted currentView is
|
|
// "show-phrase" at the moment it happens, which is precisely the state
|
|
// RESTORABLE_VIEWS has to refuse.
|
|
test("reopening the popup never lands on the phrase screen (#161)", async (env) => {
|
|
await openPhraseScreen(env.page);
|
|
await revealPhrase(env.page);
|
|
|
|
await env.page.close();
|
|
env.page = await openPopup(env.ctx, env.popupUrl);
|
|
await visible(env.page, "#view-main");
|
|
|
|
const st = await phraseScreenState(env.page);
|
|
assert(st.viewHidden, "the popup reopened onto the recovery phrase screen");
|
|
assertWiped(st, env.phrase, "after reopening the popup");
|
|
});
|
|
|
|
// ---------------------------------------------------------------- runner
|
|
|
|
async function main() {
|
|
// A suite that runs nothing must never report success. If a refactor
|
|
// drops the registrations above, or a require() of this file stops
|
|
// reaching them, the only honest outcome is a red run — reporting
|
|
// "0/0 passed" and exiting 0 is the same vacuous-check failure this
|
|
// whole harness exists to prevent.
|
|
if (tests.length === 0) {
|
|
console.log("1..0");
|
|
console.log("# FAILED: the e2e suite registered no tests");
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const routeOpts = { seedTokenTransfer: false };
|
|
|
|
let session;
|
|
try {
|
|
session = await launch(routeOpts);
|
|
} catch (e) {
|
|
// Never skip and report success: a browser we cannot start, or
|
|
// one whose network interception is not in force, is a failure of
|
|
// the suite, not an absent one.
|
|
console.error("e2e: cannot run the suite: " + e.message);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
console.log("# extension id: " + session.extensionId);
|
|
console.log("1.." + tests.length);
|
|
|
|
const env = {
|
|
ctx: session.ctx,
|
|
popupUrl: session.popupUrl,
|
|
routeOpts,
|
|
page: null,
|
|
// The recovery phrase of the wallet created in test 2, so later
|
|
// tests can assert on the real secret rather than its shape.
|
|
phrase: null,
|
|
};
|
|
|
|
// Attribution of collected errors is total. session.errors has no
|
|
// window API at all: take() always drains everything outstanding, so
|
|
// successive takes partition the whole stream, and the phases below
|
|
// cover the entire life of the run. Nothing the collector holds can
|
|
// go unread.
|
|
//
|
|
// launch .. end of test 1 -> test 1 (so the worker's startup
|
|
// fetches land on a test, not
|
|
// nowhere)
|
|
// end of test k .. end of k+1 -> test k+1
|
|
// last test .. teardown -> the suite, via the trailing drain
|
|
//
|
|
// Those three phases cover the entire life of the browser context.
|
|
// There is no fourth: once the context is closed nothing can record,
|
|
// because the route handler and the console listeners died with it.
|
|
// Traffic that a test defers past the trailing drain is therefore
|
|
// never observed at all — a real limit of this design, stated in the
|
|
// README, and not one any post-teardown hook could close.
|
|
//
|
|
// Two green-but-vacuous runs on this harness were the same shape: a
|
|
// record falling outside somebody's window and being dropped. First
|
|
// the mark started after test 1, discarding launch-time records;
|
|
// then the tail after the last test was never read. Patching a
|
|
// second boundary would have invited a third, so the window concept
|
|
// is gone rather than fixed.
|
|
let failed = 0;
|
|
let n = 0;
|
|
for (const t of tests) {
|
|
n += 1;
|
|
let failure = null;
|
|
try {
|
|
await withTimeout(t.fn(env), t.name);
|
|
} catch (e) {
|
|
failure = e.message;
|
|
}
|
|
|
|
// Any uncaught page error, console.error or unstubbed request
|
|
// fails the test that provoked it, whether or not its assertions
|
|
// passed. This is the mechanism that caught #150.
|
|
const newErrors = session.errors.take();
|
|
if (!failure && newErrors.length > 0) {
|
|
failure = "uncaught browser errors during this test";
|
|
}
|
|
|
|
if (failure) {
|
|
failed += 1;
|
|
console.log("not ok " + n + " - " + t.name);
|
|
console.log(" " + failure);
|
|
for (const line of newErrors) {
|
|
console.log(" " + line);
|
|
}
|
|
} else {
|
|
console.log("ok " + n + " - " + t.name);
|
|
}
|
|
}
|
|
|
|
// Keep watching after the last test returns, before tearing the
|
|
// browser down. A request a test fires without awaiting is still in
|
|
// flight when its function resolves; measured here it reaches the
|
|
// route handler about 10ms later, but closing the context does not
|
|
// wait for it — with no window at all the request dies unobserved
|
|
// and the run goes green, which is exactly how escaping traffic
|
|
// stays invisible.
|
|
//
|
|
// A fixed bounded window rather than a quiescence poll on purpose:
|
|
// the collector being quiet is not evidence, because a request that
|
|
// has not been dispatched yet has recorded nothing to be quiet
|
|
// about. Playwright offers no "is anything in flight" question to
|
|
// ask either — the route handler is the only observation point — so
|
|
// a grace period is the mechanism available, and this one is ~150x
|
|
// the measured latency for 1.5s on a ~25s suite.
|
|
await new Promise((resolve) => setTimeout(resolve, TRAILING_WATCH_MS));
|
|
|
|
await session.close();
|
|
|
|
// The tail. These cannot be blamed on any single test, so they are
|
|
// reported against the suite rather than guessed at — but they are
|
|
// reported, and they fail the run.
|
|
const trailing = session.errors.take();
|
|
|
|
console.log(
|
|
"# " + (tests.length - failed) + "/" + tests.length + " tests passed",
|
|
);
|
|
|
|
if (trailing.length > 0) {
|
|
console.log(
|
|
"# " +
|
|
trailing.length +
|
|
" browser error(s) recorded after the last test finished, " +
|
|
"not attributable to any single test:",
|
|
);
|
|
for (const line of trailing) {
|
|
console.log("# " + line);
|
|
}
|
|
}
|
|
|
|
if (failed > 0 || trailing.length > 0) {
|
|
console.log("# FAILED");
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("e2e: " + (e && e.stack ? e.stack : e));
|
|
process.exitCode = 1;
|
|
});
|