Files
AutistMask/tests/showPhrase.test.js
T
sneak 6fe3e9c729
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
fix: Back from Settings never lands on Settings itself (closes #481)
The recovery phrase and delete wallet screens take themselves off the
Back stack when left, so Settings, one of those screens, then the
settings gear leaves Settings under the Settings now showing. A reopened
popup restores the same stack, cut at the screen the gear left. Back
then showed Settings again and seemed to do nothing.

goBack() now skips any entry for the screen already showing before it
pops its target. Jest tests drive the gear and then Back for the
recovery phrase screen, once and twice over, for both delete screens,
and after a reopen.

Model: opus-5-5
2026-10-07 01:49:10 +00:00

196 lines
7.4 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." Taking the screen off
// the stack leaves Settings under Settings, and Back must not land there
// either (https://git.eeqj.de/sneak/AutistMask/issues/481).
test("goes to the screen under Settings", () => {
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).toBe("main");
});
// Each round trip leaves one more Settings under Settings.
test("goes to the screen under Settings after two round trips", () => {
const { helpers, state, showPhrase } = load();
for (let i = 0; i < 2; i++) {
showPhrase.show(0);
helpers.pushCurrentView();
helpers.showView("settings");
}
expect(state.viewStack).toEqual(["main", "settings", "settings"]);
helpers.goBack();
expect(state.currentView).toBe("main");
});
// 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/);
});
});