Files
AutistMask/tests/showPhrase.test.js
T
sneak eca2d15249
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
fix: Back from Settings no longer lands on a secret screen left by the gear (closes #461)
Leaving the private key export or recovery phrase screen drops the
selection it was showing, but the settings gear had just pushed the
screen onto the Back stack, so Back from Settings landed on a password
prompt that could only fail. Each screen's leave handler now also takes
it off the top of the stack, which is what a reopened popup already does
to these screens. Back from Settings goes to the address screen for the
export screen; for the recovery phrase screen, opened from Settings, it
stays on Settings once, as after a reopen.

Jest tests drive the gear and then Back, and each screen's own Back, for
both screens; leavePrivkeyScreen() in the e2e suite expects the address
screen.

Model: opus-5-5
2026-10-06 21:10:42 +00:00

180 lines
6.8 KiB
JavaScript

// Tests for the recovery phrase display (issue #161).
//
// These cover which wallet types may be offered the action at all, the
// exclusion of the screen from the set of views the popup may reopen onto,
// the absence of any path from this module to the logger, and, against a
// minimal DOM stub, where Back goes after the screen is left by the settings
// gear or by its own Back. The rest of the DOM behaviour it guards — nothing rendered before the
// password is accepted, a wrong password revealing nothing, and the wipe on
// leaving — is driven against the real popup in a real browser by
// tests/e2e/run.js, which is where every other view behaviour is tested.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
}));
const fs = require("fs");
const path = require("path");
const { walletHasRecoveryPhrase } = require("../src/shared/wallet");
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
const SHOW_PHRASE_VIEW = "show-phrase";
// helpers.js pulls in state.js, which reads chrome.storage.local at load.
function loadHelpers() {
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
return require("../src/popup/views/helpers");
}
describe("which wallets have a recovery phrase", () => {
test("an HD wallet does", () => {
expect(walletHasRecoveryPhrase({ type: "hd" })).toBe(true);
});
// A key wallet holds a bare private key and an xprv wallet an extended
// private key. Neither can be turned back into words, so neither may be
// offered the action.
test("a key wallet does not", () => {
expect(walletHasRecoveryPhrase({ type: "key" })).toBe(false);
});
test("an xprv wallet does not", () => {
expect(walletHasRecoveryPhrase({ type: "xprv" })).toBe(false);
});
test("an unknown or missing wallet type does not", () => {
expect(walletHasRecoveryPhrase({ type: "something-new" })).toBe(false);
expect(walletHasRecoveryPhrase({})).toBe(false);
expect(walletHasRecoveryPhrase(undefined)).toBe(false);
});
});
describe("views the popup may reopen onto", () => {
// Restoring onto a secret screen would put the phrase on screen with no
// password prompt in front of it, on a popup the user may have reopened
// by accident.
test("the recovery phrase screen is not restorable", () => {
expect(RESTORABLE_VIEWS.has(SHOW_PHRASE_VIEW)).toBe(false);
});
test("the private key export screen is not restorable either", () => {
expect(RESTORABLE_VIEWS.has("export-privkey")).toBe(false);
});
test("the recovery phrase screen is still a registered view", () => {
const { VIEWS } = loadHelpers();
expect(VIEWS).toContain(SHOW_PHRASE_VIEW);
});
// Guards the other direction: a restorable name that is not a real view
// would leave restoreView() showing nothing at all.
test("every restorable view is a registered view", () => {
const { VIEWS } = loadHelpers();
for (const view of RESTORABLE_VIEWS) {
expect(VIEWS).toContain(view);
}
});
});
// Just enough document for helpers.showView() and this view: every element
// is made on first lookup and keeps what the view writes to it, its click
// handler included.
function makeDocument() {
const els = new Map();
function makeElement() {
const classes = new Set();
const el = {
textContent: "",
value: "",
style: {},
classList: {
add: (name) => classes.add(name),
remove: (name) => classes.delete(name),
contains: (name) => classes.has(name),
toggle: (name, on) =>
on ? classes.add(name) : classes.delete(name),
},
addEventListener: (name, fn) => {
if (name === "click") el.onClick = fn;
},
};
return el;
}
return {
getElementById(id) {
// Created on demand by helpers.js; absent on a mainnet popup
// that is not a debug build.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement());
return els.get(id);
},
};
}
describe("Back from Settings after leaving by the settings gear", () => {
// On Settings, opened from Home.
function load() {
jest.resetModules();
globalThis.document = makeDocument();
const helpers = loadHelpers();
const { state } = require("../src/shared/state");
const showPhrase = require("../src/popup/views/showPhrase");
showPhrase.init();
state.wallets = [{ name: "Wallet 1", type: "hd", addresses: [] }];
state.currentView = "settings";
state.viewStack = ["main"];
return { helpers, state, showPhrase };
}
// https://git.eeqj.de/sneak/AutistMask/issues/461: leaving drops the
// wallet selection, so Back onto this screen showed a password prompt
// that could only answer "No wallet is selected."
test("does not land on the recovery phrase screen", () => {
const { helpers, state, showPhrase } = load();
// Opened from the wallet list in Settings, then left by the gear:
// push the current view, then show Settings.
showPhrase.show(0);
helpers.pushCurrentView();
helpers.showView("settings");
expect(state.viewStack).toEqual(["main", "settings"]);
helpers.goBack();
expect(state.currentView).not.toBe(SHOW_PHRASE_VIEW);
});
// This Back takes Settings off the stack before the screen is left, so
// the stack's top is then the entry Back from Settings will need.
test("its own Back button leaves the rest of the stack alone", () => {
const { state, showPhrase } = load();
showPhrase.show(0);
globalThis.document.getElementById("btn-show-phrase-back").onClick();
expect(state.currentView).toBe("settings");
expect(state.viewStack).toEqual(["main"]);
});
});
describe("the phrase cannot reach the logger", () => {
const source = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "views", "showPhrase.js"),
"utf8",
);
// The decrypted phrase only ever lives in a local and in the DOM node
// that displays it. The module has no logger to hand it to, and this
// pins that: src/shared/log.js writes to the console, and a console
// record of a recovery phrase outlives the popup.
test("the view does not import src/shared/log.js", () => {
expect(source).not.toMatch(/require\(["'][^"']*shared\/log["']\)/);
});
test("the view calls no logger method", () => {
expect(source).not.toMatch(/\blog\.(debugf|infof|warnf|errorf)\b/);
});
});