test: drive the private key export screen end to end #462
@@ -45,6 +45,18 @@ 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
|
||||
|
||||
+166
-30
@@ -606,22 +606,26 @@ async function openSettings(page) {
|
||||
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
|
||||
// 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"),
|
||||
}));
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
|
||||
async function openPhraseScreen(page) {
|
||||
@@ -636,12 +640,12 @@ async function revealPhrase(page) {
|
||||
await visible(page, "#show-phrase-result", 60000);
|
||||
}
|
||||
|
||||
function assertWiped(st, phrase, where) {
|
||||
assert(st.value === "", "phrase still in the DOM " + where);
|
||||
function assertWiped(st, secret, where) {
|
||||
assert(st.value === "", "the secret is 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,
|
||||
!st.html.includes(secret),
|
||||
"the secret is still somewhere in the screen markup " + where,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -663,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
|
||||
// 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.
|
||||
// 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) => {
|
||||
const { Wallet } = require("ethers");
|
||||
|
||||
@@ -671,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 visible(env.page, "#view-add-wallet");
|
||||
await env.page.click("#tab-privkey");
|
||||
await env.page.fill(
|
||||
"#import-private-key",
|
||||
Wallet.createRandom().privateKey,
|
||||
);
|
||||
env.privateKey = Wallet.createRandom().privateKey;
|
||||
await env.page.fill("#import-private-key", env.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");
|
||||
@@ -696,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) => {
|
||||
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");
|
||||
const passwordShown = await env.page.isVisible(
|
||||
"#show-phrase-password-section",
|
||||
@@ -714,7 +717,7 @@ test("a wrong password reveals nothing (#161)", async (env) => {
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
|
||||
const st = await phraseScreenState(env.page);
|
||||
const st = await secretScreenState(env.page, "show-phrase");
|
||||
assertWiped(st, env.phrase, "after a wrong password");
|
||||
assert(
|
||||
/^[A-Z].*\.$/.test(st.error.trim()),
|
||||
@@ -730,7 +733,7 @@ test("the correct password reveals the full phrase, and nothing logs it (#161)",
|
||||
try {
|
||||
await revealPhrase(env.page);
|
||||
|
||||
const st = await phraseScreenState(env.page);
|
||||
const st = await secretScreenState(env.page, "show-phrase");
|
||||
assert(
|
||||
st.value === env.phrase,
|
||||
"the displayed phrase is not the wallet's phrase, verbatim",
|
||||
@@ -761,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) => {
|
||||
await env.page.click("#btn-show-phrase-back");
|
||||
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");
|
||||
assertWiped(st, env.phrase, "after Back");
|
||||
});
|
||||
@@ -773,7 +776,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 phraseScreenState(env.page);
|
||||
const st = await secretScreenState(env.page, "show-phrase");
|
||||
assertWiped(st, env.phrase, "after leaving via the settings gear");
|
||||
});
|
||||
|
||||
@@ -810,7 +813,7 @@ test("leaving while the decrypt is in flight reveals nothing (#161)", async (env
|
||||
);
|
||||
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
|
||||
// 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
|
||||
@@ -847,11 +850,141 @@ 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 phraseScreenState(env.page);
|
||||
const st = await secretScreenState(env.page, "show-phrase");
|
||||
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,
|
||||
@@ -4201,6 +4334,9 @@ 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