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 for both screens, and leavePrivkeyScreen() in the e2e suite expects the address screen. Model: opus-5-5
158 lines
6.0 KiB
JavaScript
158 lines
6.0 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. 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.
|
|
function makeDocument() {
|
|
const els = new Map();
|
|
function makeElement() {
|
|
const classes = new Set();
|
|
return {
|
|
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: () => {},
|
|
};
|
|
}
|
|
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", () => {
|
|
// 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", () => {
|
|
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"];
|
|
|
|
// 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);
|
|
});
|
|
});
|
|
|
|
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/);
|
|
});
|
|
});
|