Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35125db6d1 |
@@ -342,6 +342,11 @@ fixtures in `tests/e2e/network.js`, so the run is deterministic and fully
|
||||
offline; unrecognised outbound requests are reported as failures rather than
|
||||
silently allowed.
|
||||
|
||||
It also covers the StateRecovery screen, under the shipped CSP: a stored record
|
||||
this build cannot read opens the popup on it, its export text box holds that
|
||||
record exactly as stored, a near-miss confirmation phrase erases nothing, and
|
||||
the exact one erases the record and reloads into Welcome.
|
||||
|
||||
It also covers the **Settings screen**, which holds the densest run of element
|
||||
id lookups in the codebase and where one wrong id leaves the whole popup blank
|
||||
rather than only degrading Settings: that the screen renders populated — the
|
||||
@@ -464,10 +469,11 @@ Chrome that ever changes this fails the run instead of passing it.
|
||||
|
||||
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
|
||||
real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver.
|
||||
It covers popup load, wallet creation through the UI, the Add Token screen, and
|
||||
the four dApp round trips — `eth_requestAccounts`, `personal_sign`,
|
||||
`eth_sendTransaction`, and a closed approval window rejecting with EIP-1193 4001
|
||||
— driven through the real content script, background page and approval windows.
|
||||
It covers popup load, the StateRecovery screen (the same cases as the Chrome
|
||||
suite), wallet creation through the UI, the Add Token screen, and the four dApp
|
||||
round trips — `eth_requestAccounts`, `personal_sign`, `eth_sendTransaction`, and
|
||||
a closed approval window rejecting with EIP-1193 4001 — driven through the real
|
||||
content script, background page and approval windows.
|
||||
|
||||
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
|
||||
|
||||
@@ -45,17 +45,15 @@ but the review is broader than any of them.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-05: The Chrome end-to-end suite drives the private key export screen
|
||||
as it drives the recovery phrase screen
|
||||
([#253](https://git.eeqj.de/sneak/AutistMask/issues/253)): the correct
|
||||
password shows the key, leaving by the settings gear empties the screen, and
|
||||
leaving while the password is still being checked never puts the key on it.
|
||||
The cases use the imported key wallet rather than the HD one. Leaving drops
|
||||
the address the screen was showing, and an HD wallet's key cannot be derived
|
||||
without it, so on an HD wallet a late decrypt fails by itself and would never
|
||||
exercise the check that discards it. The screen cannot yet be opened twice in
|
||||
one popup session ([#460](https://git.eeqj.de/sneak/AutistMask/issues/460)),
|
||||
so the cases reopen the popup before the second open.
|
||||
- 2026-10-05: The StateRecovery screen is driven in a real browser under the
|
||||
shipped CSP, in both end-to-end suites
|
||||
([#361](https://git.eeqj.de/sneak/AutistMask/issues/361)). A stored record
|
||||
this build cannot read opens the popup on it; its export text box holds the
|
||||
record exactly as stored; a near-miss confirmation phrase erases nothing; and
|
||||
the exact phrase erases the record and reloads into Welcome. The cases run
|
||||
before any wallet exists: with no wallet nothing saves on a timer, so no save
|
||||
can write a good record over the unreadable one, and the erase leaves the
|
||||
popup on Welcome for wallet creation.
|
||||
|
||||
- 2026-10-05: Escaping in the popup's views follows its own rule with no
|
||||
exceptions ([#329](https://git.eeqj.de/sneak/AutistMask/issues/329)). The
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { isDeepStrictEqual } = require("util");
|
||||
|
||||
const {
|
||||
Transaction,
|
||||
@@ -62,6 +63,10 @@ const {
|
||||
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
|
||||
const { startDappServer } = require("./dapp");
|
||||
const { STUB_COUNTERPARTY } = require("../network");
|
||||
const {
|
||||
STATE_SCHEMA_VERSION,
|
||||
stateProblem,
|
||||
} = require("../../../src/shared/stateSchema");
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
|
||||
const POPUP_URL = EXTENSION_ORIGIN + "/src/popup/index.html";
|
||||
@@ -122,6 +127,123 @@ step("the popup is drawn in the monospace font it declares", async (env) => {
|
||||
);
|
||||
});
|
||||
|
||||
// The recovery screen (#361): the Chrome suite's four cases, run before any
|
||||
// wallet exists for the same reason. With no wallet nothing saves on a timer,
|
||||
// so no save can write a good record over the unreadable one. The last of them
|
||||
// erases it, which leaves the popup on Welcome for wallet creation.
|
||||
|
||||
// A profile a newer build wrote: a wallet with its encrypted secret, under a
|
||||
// schema version this build refuses to read.
|
||||
const UNREADABLE_RECORD = {
|
||||
schemaVersion: STATE_SCHEMA_VERSION + 1,
|
||||
wallets: [
|
||||
{
|
||||
type: "hd",
|
||||
name: "Main",
|
||||
xpub: "xpub-written-by-a-newer-build",
|
||||
encryptedSecret: "ciphertext-written-by-a-newer-build",
|
||||
nextIndex: 1,
|
||||
addresses: [{ address: STUB_COUNTERPARTY }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// The whole stored record, read on the popup page.
|
||||
function storedRecord(d) {
|
||||
return d.executeAsync(
|
||||
`const done = arguments[arguments.length - 1];
|
||||
browser.storage.local.get("autistmask").then((r) => done(r.autistmask));`,
|
||||
);
|
||||
}
|
||||
|
||||
step(
|
||||
"an unreadable stored record opens the popup on the recovery screen",
|
||||
async (env) => {
|
||||
const d = env.driver;
|
||||
// The popup the first step opened saves once, as it shows Welcome.
|
||||
// Stored before that save lands, the record would be written over.
|
||||
const deadline = Date.now() + 15000;
|
||||
for (;;) {
|
||||
const stored = await storedRecord(d);
|
||||
if (stored && stored.currentView === "welcome") break;
|
||||
assert(
|
||||
Date.now() < deadline,
|
||||
"the Welcome screen's save never landed: " +
|
||||
JSON.stringify(stored),
|
||||
);
|
||||
await sleep(100);
|
||||
}
|
||||
await d.executeAsync(
|
||||
`const done = arguments[arguments.length - 1];
|
||||
browser.storage.local.set({ autistmask: arguments[0] }).then(() => done());`,
|
||||
[UNREADABLE_RECORD],
|
||||
);
|
||||
|
||||
await d.navigate(POPUP_URL);
|
||||
await d.waitVisible("#view-state-recovery");
|
||||
const problem = await d.text("#state-recovery-problem");
|
||||
assert(
|
||||
problem === stateProblem(UNREADABLE_RECORD),
|
||||
"the recovery screen names the problem as " +
|
||||
JSON.stringify(problem),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
step("Export Saved Data shows the stored record verbatim", async (env) => {
|
||||
const d = env.driver;
|
||||
await d.click("#btn-state-recovery-export");
|
||||
await d.waitVisible("#state-recovery-blob");
|
||||
const exported = await d.value("#state-recovery-blob");
|
||||
assert(exported !== "", "Export Saved Data left the text box empty");
|
||||
assert(
|
||||
isDeepStrictEqual(JSON.parse(exported), UNREADABLE_RECORD),
|
||||
"the text box does not hold the stored record: " + exported,
|
||||
);
|
||||
});
|
||||
|
||||
step("a near-miss confirmation phrase erases nothing", async (env) => {
|
||||
const d = env.driver;
|
||||
await d.fill("#state-recovery-reset-input", "ERASE MY WALLETS");
|
||||
await d.click("#btn-state-recovery-reset");
|
||||
await d.waitFor(
|
||||
"the refusal on the error line",
|
||||
`return document.getElementById("state-recovery-flash").textContent ===
|
||||
"Type ERASE MY WALLET to confirm. Nothing was erased.";`,
|
||||
);
|
||||
const stored = await storedRecord(d);
|
||||
assert(
|
||||
isDeepStrictEqual(stored, UNREADABLE_RECORD),
|
||||
"the stored record changed: " + JSON.stringify(stored),
|
||||
);
|
||||
});
|
||||
|
||||
step(
|
||||
"the exact confirmation phrase erases the record and reloads into Welcome",
|
||||
async (env) => {
|
||||
const d = env.driver;
|
||||
try {
|
||||
await d.fill("#state-recovery-reset-input", "ERASE MY WALLET");
|
||||
await d.click("#btn-state-recovery-reset");
|
||||
// Welcome is the proof of the erase: the record still stored
|
||||
// would put the recovery screen up again, and its wallet would
|
||||
// open Home.
|
||||
await d.waitVisible("#view-welcome");
|
||||
} finally {
|
||||
// Whatever failed in these four steps, wallet creation starts
|
||||
// from Welcome. The record left stored would fail every step
|
||||
// after this.
|
||||
if (!(await d.isVisible("#view-welcome"))) {
|
||||
await d.executeAsync(
|
||||
`const done = arguments[arguments.length - 1];
|
||||
browser.storage.local.remove("autistmask").then(() => done());`,
|
||||
);
|
||||
await d.navigate(POPUP_URL);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
step("wallet creation through the UI reaches the main view", async (env) => {
|
||||
const d = env.driver;
|
||||
await d.click("#btn-welcome-add");
|
||||
|
||||
+153
-168
@@ -9,6 +9,8 @@
|
||||
|
||||
"use strict";
|
||||
|
||||
const { isDeepStrictEqual } = require("util");
|
||||
|
||||
const {
|
||||
Transaction,
|
||||
formatEther,
|
||||
@@ -47,6 +49,10 @@ const {
|
||||
} = require("./network");
|
||||
const { DUST_THRESHOLD_MESSAGE } = require("../../src/popup/dustThreshold");
|
||||
const { NETWORKS } = require("../../src/shared/networks");
|
||||
const {
|
||||
STATE_SCHEMA_VERSION,
|
||||
stateProblem,
|
||||
} = require("../../src/shared/stateSchema");
|
||||
|
||||
const TEST_TIMEOUT_MS = 120000;
|
||||
|
||||
@@ -115,8 +121,8 @@ test("the popup is drawn in the monospace font it declares (#418)", async (env)
|
||||
// so a recurrence fails whichever test it lands in rather than being
|
||||
// tolerated. Since libsodium's WASM module is embedded in the bundle and
|
||||
// needs no fetch, a realm that compiles WASM is a realm where libsodium
|
||||
// takes the WASM path, and the next test drives a real vault encryption
|
||||
// through it.
|
||||
// takes the WASM path, and wallet creation below drives a real vault
|
||||
// encryption through it.
|
||||
test("the popup compiles WebAssembly under the shipped CSP (#182)", async (env) => {
|
||||
const ok = await pageCompilesWasm(env.page);
|
||||
assert(
|
||||
@@ -128,6 +134,121 @@ test("the popup compiles WebAssembly under the shipped CSP (#182)", async (env)
|
||||
);
|
||||
});
|
||||
|
||||
// The screen the popup shows when it cannot read the stored profile
|
||||
// (src/popup/views/stateRecovery.js), driven under the shipped CSP (#361).
|
||||
//
|
||||
// These run before any wallet exists, on purpose. With no wallet neither the
|
||||
// popup nor the background refreshes balances, so nothing saves while they run
|
||||
// and no save can write a good record over the unreadable one. The last of
|
||||
// them erases it, which leaves the popup on Welcome for wallet creation.
|
||||
|
||||
// A profile a newer build wrote: a wallet with its encrypted secret, under a
|
||||
// schema version this build refuses to read.
|
||||
const UNREADABLE_RECORD = {
|
||||
schemaVersion: STATE_SCHEMA_VERSION + 1,
|
||||
wallets: [
|
||||
{
|
||||
type: "hd",
|
||||
name: "Main",
|
||||
xpub: "xpub-written-by-a-newer-build",
|
||||
encryptedSecret: "ciphertext-written-by-a-newer-build",
|
||||
nextIndex: 1,
|
||||
addresses: [{ address: STUB_COUNTERPARTY }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// The whole stored record, read out of extension storage.
|
||||
function storedRecord(page) {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
chrome.storage.local.get("autistmask", (r) =>
|
||||
resolve(r.autistmask),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test("an unreadable stored record opens the popup on the recovery screen (#361)", async (env) => {
|
||||
// The popup the first test opened saves once, as it shows Welcome. Stored
|
||||
// before that save lands, the record would be written over.
|
||||
await waitForPersisted(
|
||||
env.page,
|
||||
"currentView",
|
||||
"welcome",
|
||||
"before the unreadable record is stored",
|
||||
);
|
||||
await env.page.evaluate(
|
||||
(record) =>
|
||||
new Promise((resolve) => {
|
||||
chrome.storage.local.set({ autistmask: record }, resolve);
|
||||
}),
|
||||
UNREADABLE_RECORD,
|
||||
);
|
||||
await env.page.close();
|
||||
|
||||
env.errors.expect(
|
||||
"the recovery screen logging the problem as it goes up",
|
||||
/state is unusable, showing the recovery screen/,
|
||||
);
|
||||
env.page = await openPopup(env.ctx, env.popupUrl);
|
||||
await visible(env.page, "#view-state-recovery");
|
||||
const problem = await env.page.textContent("#state-recovery-problem");
|
||||
assert(
|
||||
problem === stateProblem(UNREADABLE_RECORD),
|
||||
"the recovery screen names the problem as " + JSON.stringify(problem),
|
||||
);
|
||||
});
|
||||
|
||||
test("Export Saved Data shows the stored record verbatim (#361)", async (env) => {
|
||||
await env.page.click("#btn-state-recovery-export");
|
||||
await visible(env.page, "#state-recovery-blob");
|
||||
const exported = await env.page.inputValue("#state-recovery-blob");
|
||||
assert(exported !== "", "Export Saved Data left the text box empty");
|
||||
assert(
|
||||
isDeepStrictEqual(JSON.parse(exported), UNREADABLE_RECORD),
|
||||
"the text box does not hold the stored record: " + exported,
|
||||
);
|
||||
});
|
||||
|
||||
test("a near-miss confirmation phrase erases nothing (#361)", async (env) => {
|
||||
await env.page.fill("#state-recovery-reset-input", "ERASE MY WALLETS");
|
||||
await env.page.click("#btn-state-recovery-reset");
|
||||
await env.page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById("state-recovery-flash").textContent ===
|
||||
"Type ERASE MY WALLET to confirm. Nothing was erased.",
|
||||
);
|
||||
const stored = await storedRecord(env.page);
|
||||
assert(
|
||||
isDeepStrictEqual(stored, UNREADABLE_RECORD),
|
||||
"the stored record changed: " + JSON.stringify(stored),
|
||||
);
|
||||
});
|
||||
|
||||
test("the exact confirmation phrase erases the record and reloads into Welcome (#361)", async (env) => {
|
||||
try {
|
||||
await env.page.fill("#state-recovery-reset-input", "ERASE MY WALLET");
|
||||
await env.page.click("#btn-state-recovery-reset");
|
||||
// Welcome is the proof of the erase: the record still stored would
|
||||
// put the recovery screen up again, and its wallet would open Home.
|
||||
await visible(env.page, "#view-welcome");
|
||||
} finally {
|
||||
// Whatever failed in these four tests, wallet creation starts from
|
||||
// Welcome. The record left stored would fail every test after this.
|
||||
if (!(await env.page.isVisible("#view-welcome"))) {
|
||||
await env.page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
chrome.storage.local.remove("autistmask", resolve);
|
||||
}),
|
||||
);
|
||||
await env.page.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("wallet creation through the UI reaches the main view", async (env) => {
|
||||
env.phrase = await createWallet(env.page);
|
||||
assert(
|
||||
@@ -485,26 +606,22 @@ async function openSettings(page) {
|
||||
await visible(page, "#view-settings");
|
||||
}
|
||||
|
||||
// Everything a screen that shows a secret is holding, read straight out of
|
||||
// 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. `view` is "show-phrase" or "export-privkey"; the two
|
||||
// screens name their elements the same way.
|
||||
async function secretScreenState(page, view) {
|
||||
return page.evaluate(
|
||||
(v) => ({
|
||||
value: document.getElementById(v + "-value").textContent,
|
||||
error: document.getElementById(v + "-flash").textContent,
|
||||
html: document.getElementById("view-" + v).innerHTML,
|
||||
resultHidden: document
|
||||
.getElementById(v + "-result")
|
||||
.classList.contains("hidden"),
|
||||
viewHidden: document
|
||||
.getElementById("view-" + v)
|
||||
.classList.contains("hidden"),
|
||||
}),
|
||||
view,
|
||||
);
|
||||
// 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) {
|
||||
@@ -519,12 +636,12 @@ async function revealPhrase(page) {
|
||||
await visible(page, "#show-phrase-result", 60000);
|
||||
}
|
||||
|
||||
function assertWiped(st, secret, where) {
|
||||
assert(st.value === "", "the secret is still in the DOM " + where);
|
||||
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(secret),
|
||||
"the secret is still somewhere in the screen markup " + where,
|
||||
!st.html.includes(phrase),
|
||||
"the recovery phrase is still somewhere in the screen markup " + where,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -546,8 +663,7 @@ test("only an HD wallet is offered the recovery phrase action (#161)", async (en
|
||||
// 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. It is kept on env for the private key
|
||||
// export tests (#253).
|
||||
// private keys, test ones included.
|
||||
test("a key wallet is not offered the recovery phrase action (#161)", async (env) => {
|
||||
const { Wallet } = require("ethers");
|
||||
|
||||
@@ -555,8 +671,10 @@ test("a key wallet is not offered the recovery phrase action (#161)", async (env
|
||||
await env.page.click("#btn-main-add-wallet");
|
||||
await visible(env.page, "#view-add-wallet");
|
||||
await env.page.click("#tab-privkey");
|
||||
env.privateKey = Wallet.createRandom().privateKey;
|
||||
await env.page.fill("#import-private-key", env.privateKey);
|
||||
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");
|
||||
@@ -578,7 +696,7 @@ test("a key wallet is not offered the recovery phrase action (#161)", async (env
|
||||
|
||||
test("the recovery phrase screen holds nothing before the password (#161)", async (env) => {
|
||||
await openPhraseScreen(env.page);
|
||||
const st = await secretScreenState(env.page, "show-phrase");
|
||||
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",
|
||||
@@ -596,7 +714,7 @@ test("a wrong password reveals nothing (#161)", async (env) => {
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
|
||||
const st = await secretScreenState(env.page, "show-phrase");
|
||||
const st = await phraseScreenState(env.page);
|
||||
assertWiped(st, env.phrase, "after a wrong password");
|
||||
assert(
|
||||
/^[A-Z].*\.$/.test(st.error.trim()),
|
||||
@@ -612,7 +730,7 @@ test("the correct password reveals the full phrase, and nothing logs it (#161)",
|
||||
try {
|
||||
await revealPhrase(env.page);
|
||||
|
||||
const st = await secretScreenState(env.page, "show-phrase");
|
||||
const st = await phraseScreenState(env.page);
|
||||
assert(
|
||||
st.value === env.phrase,
|
||||
"the displayed phrase is not the wallet's phrase, verbatim",
|
||||
@@ -643,7 +761,7 @@ test("the correct password reveals the full phrase, and nothing logs it (#161)",
|
||||
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 secretScreenState(env.page, "show-phrase");
|
||||
const st = await phraseScreenState(env.page);
|
||||
assert(st.viewHidden, "the recovery phrase screen is still on top");
|
||||
assertWiped(st, env.phrase, "after Back");
|
||||
});
|
||||
@@ -655,7 +773,7 @@ test("leaving by the settings gear wipes it too (#161)", async (env) => {
|
||||
await revealPhrase(env.page);
|
||||
await env.page.click("#btn-settings");
|
||||
await visible(env.page, "#view-settings");
|
||||
const st = await secretScreenState(env.page, "show-phrase");
|
||||
const st = await phraseScreenState(env.page);
|
||||
assertWiped(st, env.phrase, "after leaving via the settings gear");
|
||||
});
|
||||
|
||||
@@ -692,7 +810,7 @@ test("leaving while the decrypt is in flight reveals nothing (#161)", async (env
|
||||
);
|
||||
await sleep(2000);
|
||||
|
||||
const st = await secretScreenState(env.page, "show-phrase");
|
||||
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
|
||||
@@ -729,141 +847,11 @@ test("reopening the popup never lands on the phrase screen (#161)", async (env)
|
||||
env.page = await openPopup(env.ctx, env.popupUrl);
|
||||
await visible(env.page, "#view-main");
|
||||
|
||||
const st = await secretScreenState(env.page, "show-phrase");
|
||||
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");
|
||||
});
|
||||
|
||||
// ------------------------------------------ private key export (#253)
|
||||
|
||||
// The recovery phrase cases above, on the private key export screen. They run
|
||||
// against the key wallet imported above, not the HD wallet: leaving the screen
|
||||
// drops the address it was showing, and without one an HD wallet's key cannot
|
||||
// be derived, so there a decrypt that finished late would fail on its own and
|
||||
// the liveness check would go untested.
|
||||
|
||||
// From Home to the export screen of the key wallet's one address. The key
|
||||
// wallet is the second wallet in the list.
|
||||
async function openPrivkeyScreen(page) {
|
||||
await visible(page, "#view-main");
|
||||
await page.click('#wallet-list .btn-addr-info[data-wallet="1"]');
|
||||
await visible(page, "#view-address");
|
||||
await page.click("#btn-more-menu");
|
||||
await page.click("#btn-export-privkey");
|
||||
await visible(page, "#view-export-privkey");
|
||||
}
|
||||
|
||||
async function revealPrivkey(page) {
|
||||
await page.fill("#export-privkey-password", PASSWORD);
|
||||
await page.click("#btn-export-privkey-confirm");
|
||||
await visible(page, "#export-privkey-result", 60000);
|
||||
}
|
||||
|
||||
// Leave the export screen, or the Settings screen the gear left it for, for
|
||||
// Home. The gear put the export screen on the Back stack, so from Settings the
|
||||
// way home passes through it, already emptied
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/461).
|
||||
async function leavePrivkeyScreen(page) {
|
||||
if (await page.isVisible("#view-settings")) {
|
||||
await page.click("#btn-settings-back");
|
||||
await visible(page, "#view-export-privkey");
|
||||
}
|
||||
if (await page.isVisible("#view-export-privkey")) {
|
||||
await page.click("#btn-export-privkey-back");
|
||||
await visible(page, "#view-address");
|
||||
}
|
||||
if (await page.isVisible("#view-address")) {
|
||||
await page.click("#btn-address-back");
|
||||
}
|
||||
await visible(page, "#view-main");
|
||||
}
|
||||
|
||||
test("the correct password reveals the private key, and nothing logs it (#253)", async (env) => {
|
||||
const console_ = [];
|
||||
const listener = (msg) => console_.push(msg.text());
|
||||
env.page.on("console", listener);
|
||||
try {
|
||||
await openPrivkeyScreen(env.page);
|
||||
await revealPrivkey(env.page);
|
||||
|
||||
const st = await secretScreenState(env.page, "export-privkey");
|
||||
assert(
|
||||
st.value === env.privateKey,
|
||||
"the displayed key is not the wallet's private key, verbatim",
|
||||
);
|
||||
const promptShown = await env.page.isVisible(
|
||||
"#export-privkey-password-section",
|
||||
);
|
||||
assert(!promptShown, "the password prompt is still shown after unlock");
|
||||
|
||||
const title = await env.page.getAttribute(
|
||||
"#export-privkey-value",
|
||||
"title",
|
||||
);
|
||||
assert(title === "Click to copy", "the key is not click-to-copy");
|
||||
|
||||
const leaked = console_.filter((line) => line.includes(env.privateKey));
|
||||
assert(
|
||||
leaked.length === 0,
|
||||
"the private key reached the console: " + JSON.stringify(leaked),
|
||||
);
|
||||
} finally {
|
||||
env.page.off("console", listener);
|
||||
}
|
||||
});
|
||||
|
||||
test("leaving by the settings gear wipes the private key (#253)", async (env) => {
|
||||
await visible(env.page, "#export-privkey-result");
|
||||
await env.page.click("#btn-settings");
|
||||
await visible(env.page, "#view-settings");
|
||||
const st = await secretScreenState(env.page, "export-privkey");
|
||||
assertWiped(st, env.privateKey, "after leaving via the settings gear");
|
||||
});
|
||||
|
||||
// The same interleaving as the recovery phrase case above, and for the same
|
||||
// reason: both clicks in one page task, so the leave and its wipe run while
|
||||
// the decrypt is still awaited. Reveal stays disabled while the decrypt runs,
|
||||
// so reading it after the gear click shows the leave really came mid-decrypt.
|
||||
test("leaving while the decrypt is in flight reveals no private key (#253)", async (env) => {
|
||||
try {
|
||||
await leavePrivkeyScreen(env.page);
|
||||
// The export screen cannot yet be opened twice in one popup session
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/460), so this second
|
||||
// open gets a fresh one.
|
||||
await reopenPopup(env, "main");
|
||||
await openPrivkeyScreen(env.page);
|
||||
await env.page.fill("#export-privkey-password", PASSWORD);
|
||||
const inFlight = await env.page.evaluate(() => {
|
||||
const reveal = document.getElementById(
|
||||
"btn-export-privkey-confirm",
|
||||
);
|
||||
reveal.click();
|
||||
document.getElementById("btn-settings").click();
|
||||
return reveal.disabled;
|
||||
});
|
||||
assert(
|
||||
inFlight,
|
||||
"the decrypt was not running when the screen was left",
|
||||
);
|
||||
await visible(env.page, "#view-settings");
|
||||
|
||||
// Reveal is re-enabled in the same continuation that would have
|
||||
// written the key, so once it is back the decrypt has finished.
|
||||
await env.page.waitForFunction(
|
||||
() =>
|
||||
!document.getElementById("btn-export-privkey-confirm").disabled,
|
||||
null,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
|
||||
const st = await secretScreenState(env.page, "export-privkey");
|
||||
assert(st.viewHidden, "the private key screen is still on top");
|
||||
assertWiped(st, env.privateKey, "after leaving mid-decrypt");
|
||||
} finally {
|
||||
await leavePrivkeyScreen(env.page);
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------- Back after reopening the popup (#268)
|
||||
|
||||
// A reopened popup renders the wallet list and the view it restores onto,
|
||||
@@ -4213,9 +4201,6 @@ async function main() {
|
||||
// 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,
|
||||
// The private key of the key wallet imported by the recovery phrase
|
||||
// tests (#161), asserted on by the private key export tests (#253).
|
||||
privateKey: null,
|
||||
// What the Settings section (#229) actually observed. A guard test
|
||||
// at the end of that section demands the full set, so a skipped or
|
||||
// silently shortened assertion reddens the run instead of shrinking
|
||||
|
||||
Reference in New Issue
Block a user