diff --git a/TODO.md b/TODO.md index 4a5d041..d752032 100644 --- a/TODO.md +++ b/TODO.md @@ -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: Escaping in the popup's views follows its own rule with no 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 diff --git a/tests/e2e/run.js b/tests/e2e/run.js index d851832..20aac51 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -485,22 +485,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) { @@ -515,12 +519,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, ); } @@ -542,7 +546,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"); @@ -550,10 +555,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"); @@ -575,7 +578,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", @@ -593,7 +596,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()), @@ -609,7 +612,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", @@ -640,7 +643,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"); }); @@ -652,7 +655,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"); }); @@ -689,7 +692,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 @@ -726,11 +729,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, @@ -4080,6 +4213,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