Compare commits
2
Commits
c5cd3fe330
...
7111d8be36
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7111d8be36 | ||
|
|
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
|
offline; unrecognised outbound requests are reported as failures rather than
|
||||||
silently allowed.
|
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
|
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
|
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
|
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
|
`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.
|
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
|
It covers popup load, the StateRecovery screen (the same cases as the Chrome
|
||||||
the four dApp round trips — `eth_requestAccounts`, `personal_sign`,
|
suite), wallet creation through the UI, the Add Token screen, and the four dApp
|
||||||
`eth_sendTransaction`, and a closed approval window rejecting with EIP-1193 4001
|
round trips — `eth_requestAccounts`, `personal_sign`, `eth_sendTransaction`, and
|
||||||
— driven through the real content script, background page and approval windows.
|
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
|
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
|
||||||
|
|||||||
@@ -45,6 +45,28 @@ but the review is broader than any of them.
|
|||||||
|
|
||||||
# Completed Steps
|
# 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
|
- 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
|
exceptions ([#329](https://git.eeqj.de/sneak/AutistMask/issues/329)). The
|
||||||
decimals and holder count on a token's screen, and every USD figure (the ETH
|
decimals and holder count on a token's screen, and every USD figure (the ETH
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
const { isDeepStrictEqual } = require("util");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
Transaction,
|
Transaction,
|
||||||
@@ -62,6 +63,10 @@ const {
|
|||||||
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
|
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
|
||||||
const { startDappServer } = require("./dapp");
|
const { startDappServer } = require("./dapp");
|
||||||
const { STUB_COUNTERPARTY } = require("../network");
|
const { STUB_COUNTERPARTY } = require("../network");
|
||||||
|
const {
|
||||||
|
STATE_SCHEMA_VERSION,
|
||||||
|
stateProblem,
|
||||||
|
} = require("../../../src/shared/stateSchema");
|
||||||
|
|
||||||
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
|
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
|
||||||
const POPUP_URL = EXTENSION_ORIGIN + "/src/popup/index.html";
|
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) => {
|
step("wallet creation through the UI reaches the main view", async (env) => {
|
||||||
const d = env.driver;
|
const d = env.driver;
|
||||||
await d.click("#btn-welcome-add");
|
await d.click("#btn-welcome-add");
|
||||||
|
|||||||
+289
-32
@@ -9,6 +9,8 @@
|
|||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
const { isDeepStrictEqual } = require("util");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
Transaction,
|
Transaction,
|
||||||
formatEther,
|
formatEther,
|
||||||
@@ -47,6 +49,10 @@ const {
|
|||||||
} = require("./network");
|
} = require("./network");
|
||||||
const { DUST_THRESHOLD_MESSAGE } = require("../../src/popup/dustThreshold");
|
const { DUST_THRESHOLD_MESSAGE } = require("../../src/popup/dustThreshold");
|
||||||
const { NETWORKS } = require("../../src/shared/networks");
|
const { NETWORKS } = require("../../src/shared/networks");
|
||||||
|
const {
|
||||||
|
STATE_SCHEMA_VERSION,
|
||||||
|
stateProblem,
|
||||||
|
} = require("../../src/shared/stateSchema");
|
||||||
|
|
||||||
const TEST_TIMEOUT_MS = 120000;
|
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
|
// so a recurrence fails whichever test it lands in rather than being
|
||||||
// tolerated. Since libsodium's WASM module is embedded in the bundle and
|
// 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
|
// 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
|
// takes the WASM path, and wallet creation below drives a real vault
|
||||||
// through it.
|
// encryption through it.
|
||||||
test("the popup compiles WebAssembly under the shipped CSP (#182)", async (env) => {
|
test("the popup compiles WebAssembly under the shipped CSP (#182)", async (env) => {
|
||||||
const ok = await pageCompilesWasm(env.page);
|
const ok = await pageCompilesWasm(env.page);
|
||||||
assert(
|
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) => {
|
test("wallet creation through the UI reaches the main view", async (env) => {
|
||||||
env.phrase = await createWallet(env.page);
|
env.phrase = await createWallet(env.page);
|
||||||
assert(
|
assert(
|
||||||
@@ -485,22 +606,26 @@ async function openSettings(page) {
|
|||||||
await visible(page, "#view-settings");
|
await visible(page, "#view-settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything the recovery phrase screen is holding, read straight out of
|
// Everything a screen that shows a secret is holding, read straight out of
|
||||||
// the DOM whether or not that screen is the one on top. Reading it while it
|
// 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
|
// is hidden is the point: "cleared on leave" means the node is empty, not
|
||||||
// merely off-screen.
|
// merely off-screen. `view` is "show-phrase" or "export-privkey"; the two
|
||||||
async function phraseScreenState(page) {
|
// screens name their elements the same way.
|
||||||
return page.evaluate(() => ({
|
async function secretScreenState(page, view) {
|
||||||
value: document.getElementById("show-phrase-value").textContent,
|
return page.evaluate(
|
||||||
error: document.getElementById("show-phrase-flash").textContent,
|
(v) => ({
|
||||||
html: document.getElementById("view-show-phrase").innerHTML,
|
value: document.getElementById(v + "-value").textContent,
|
||||||
resultHidden: document
|
error: document.getElementById(v + "-flash").textContent,
|
||||||
.getElementById("show-phrase-result")
|
html: document.getElementById("view-" + v).innerHTML,
|
||||||
.classList.contains("hidden"),
|
resultHidden: document
|
||||||
viewHidden: document
|
.getElementById(v + "-result")
|
||||||
.getElementById("view-show-phrase")
|
.classList.contains("hidden"),
|
||||||
.classList.contains("hidden"),
|
viewHidden: document
|
||||||
}));
|
.getElementById("view-" + v)
|
||||||
|
.classList.contains("hidden"),
|
||||||
|
}),
|
||||||
|
view,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openPhraseScreen(page) {
|
async function openPhraseScreen(page) {
|
||||||
@@ -515,12 +640,12 @@ async function revealPhrase(page) {
|
|||||||
await visible(page, "#show-phrase-result", 60000);
|
await visible(page, "#show-phrase-result", 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertWiped(st, phrase, where) {
|
function assertWiped(st, secret, where) {
|
||||||
assert(st.value === "", "phrase still in the DOM " + where);
|
assert(st.value === "", "the secret is still in the DOM " + where);
|
||||||
assert(st.resultHidden, "result section still shown " + where);
|
assert(st.resultHidden, "result section still shown " + where);
|
||||||
assert(
|
assert(
|
||||||
!st.html.includes(phrase),
|
!st.html.includes(secret),
|
||||||
"the recovery phrase is still somewhere in the screen markup " + where,
|
"the secret is still somewhere in the screen markup " + where,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,7 +667,8 @@ 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
|
// 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.
|
// 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
|
// The key is generated here rather than committed — the repo holds no
|
||||||
// private keys, test ones included.
|
// private keys, test ones included. It is kept on env for the private key
|
||||||
|
// export tests (#253).
|
||||||
test("a key wallet is not offered the recovery phrase action (#161)", async (env) => {
|
test("a key wallet is not offered the recovery phrase action (#161)", async (env) => {
|
||||||
const { Wallet } = require("ethers");
|
const { Wallet } = require("ethers");
|
||||||
|
|
||||||
@@ -550,10 +676,8 @@ test("a key wallet is not offered the recovery phrase action (#161)", async (env
|
|||||||
await env.page.click("#btn-main-add-wallet");
|
await env.page.click("#btn-main-add-wallet");
|
||||||
await visible(env.page, "#view-add-wallet");
|
await visible(env.page, "#view-add-wallet");
|
||||||
await env.page.click("#tab-privkey");
|
await env.page.click("#tab-privkey");
|
||||||
await env.page.fill(
|
env.privateKey = Wallet.createRandom().privateKey;
|
||||||
"#import-private-key",
|
await env.page.fill("#import-private-key", env.privateKey);
|
||||||
Wallet.createRandom().privateKey,
|
|
||||||
);
|
|
||||||
await env.page.fill("#add-wallet-password", PASSWORD);
|
await env.page.fill("#add-wallet-password", PASSWORD);
|
||||||
await env.page.fill("#add-wallet-password-confirm", PASSWORD);
|
await env.page.fill("#add-wallet-password-confirm", PASSWORD);
|
||||||
await env.page.click("#btn-add-wallet-confirm");
|
await env.page.click("#btn-add-wallet-confirm");
|
||||||
@@ -575,7 +699,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) => {
|
test("the recovery phrase screen holds nothing before the password (#161)", async (env) => {
|
||||||
await openPhraseScreen(env.page);
|
await openPhraseScreen(env.page);
|
||||||
const st = await phraseScreenState(env.page);
|
const st = await secretScreenState(env.page, "show-phrase");
|
||||||
assertWiped(st, env.phrase, "before any password was entered");
|
assertWiped(st, env.phrase, "before any password was entered");
|
||||||
const passwordShown = await env.page.isVisible(
|
const passwordShown = await env.page.isVisible(
|
||||||
"#show-phrase-password-section",
|
"#show-phrase-password-section",
|
||||||
@@ -593,7 +717,7 @@ test("a wrong password reveals nothing (#161)", async (env) => {
|
|||||||
{ timeout: 60000 },
|
{ timeout: 60000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
const st = await phraseScreenState(env.page);
|
const st = await secretScreenState(env.page, "show-phrase");
|
||||||
assertWiped(st, env.phrase, "after a wrong password");
|
assertWiped(st, env.phrase, "after a wrong password");
|
||||||
assert(
|
assert(
|
||||||
/^[A-Z].*\.$/.test(st.error.trim()),
|
/^[A-Z].*\.$/.test(st.error.trim()),
|
||||||
@@ -609,7 +733,7 @@ test("the correct password reveals the full phrase, and nothing logs it (#161)",
|
|||||||
try {
|
try {
|
||||||
await revealPhrase(env.page);
|
await revealPhrase(env.page);
|
||||||
|
|
||||||
const st = await phraseScreenState(env.page);
|
const st = await secretScreenState(env.page, "show-phrase");
|
||||||
assert(
|
assert(
|
||||||
st.value === env.phrase,
|
st.value === env.phrase,
|
||||||
"the displayed phrase is not the wallet's phrase, verbatim",
|
"the displayed phrase is not the wallet's phrase, verbatim",
|
||||||
@@ -640,7 +764,7 @@ test("the correct password reveals the full phrase, and nothing logs it (#161)",
|
|||||||
test('"Back" wipes the revealed phrase (#161)', async (env) => {
|
test('"Back" wipes the revealed phrase (#161)', async (env) => {
|
||||||
await env.page.click("#btn-show-phrase-back");
|
await env.page.click("#btn-show-phrase-back");
|
||||||
await visible(env.page, "#view-settings");
|
await visible(env.page, "#view-settings");
|
||||||
const st = await phraseScreenState(env.page);
|
const st = await secretScreenState(env.page, "show-phrase");
|
||||||
assert(st.viewHidden, "the recovery phrase screen is still on top");
|
assert(st.viewHidden, "the recovery phrase screen is still on top");
|
||||||
assertWiped(st, env.phrase, "after Back");
|
assertWiped(st, env.phrase, "after Back");
|
||||||
});
|
});
|
||||||
@@ -652,7 +776,7 @@ test("leaving by the settings gear wipes it too (#161)", async (env) => {
|
|||||||
await revealPhrase(env.page);
|
await revealPhrase(env.page);
|
||||||
await env.page.click("#btn-settings");
|
await env.page.click("#btn-settings");
|
||||||
await visible(env.page, "#view-settings");
|
await visible(env.page, "#view-settings");
|
||||||
const st = await phraseScreenState(env.page);
|
const st = await secretScreenState(env.page, "show-phrase");
|
||||||
assertWiped(st, env.phrase, "after leaving via the settings gear");
|
assertWiped(st, env.phrase, "after leaving via the settings gear");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -689,7 +813,7 @@ test("leaving while the decrypt is in flight reveals nothing (#161)", async (env
|
|||||||
);
|
);
|
||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
|
|
||||||
const st = await phraseScreenState(env.page);
|
const st = await secretScreenState(env.page, "show-phrase");
|
||||||
// Printed on every run, pass or fail: "the phrase is not there" is
|
// 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
|
// 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
|
// same line read from a build without the guard is what this test
|
||||||
@@ -726,11 +850,141 @@ test("reopening the popup never lands on the phrase screen (#161)", async (env)
|
|||||||
env.page = await openPopup(env.ctx, env.popupUrl);
|
env.page = await openPopup(env.ctx, env.popupUrl);
|
||||||
await visible(env.page, "#view-main");
|
await visible(env.page, "#view-main");
|
||||||
|
|
||||||
const st = await phraseScreenState(env.page);
|
const st = await secretScreenState(env.page, "show-phrase");
|
||||||
assert(st.viewHidden, "the popup reopened onto the recovery phrase screen");
|
assert(st.viewHidden, "the popup reopened onto the recovery phrase screen");
|
||||||
assertWiped(st, env.phrase, "after reopening the popup");
|
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)
|
// ------------------------------- Back after reopening the popup (#268)
|
||||||
|
|
||||||
// A reopened popup renders the wallet list and the view it restores onto,
|
// A reopened popup renders the wallet list and the view it restores onto,
|
||||||
@@ -4080,6 +4334,9 @@ async function main() {
|
|||||||
// The recovery phrase of the wallet created in test 2, so later
|
// The recovery phrase of the wallet created in test 2, so later
|
||||||
// tests can assert on the real secret rather than its shape.
|
// tests can assert on the real secret rather than its shape.
|
||||||
phrase: null,
|
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
|
// What the Settings section (#229) actually observed. A guard test
|
||||||
// at the end of that section demands the full set, so a skipped or
|
// at the end of that section demands the full set, so a skipped or
|
||||||
// silently shortened assertion reddens the run instead of shrinking
|
// silently shortened assertion reddens the run instead of shrinking
|
||||||
|
|||||||
Reference in New Issue
Block a user