Five defects traced to one fact: src/background/index.js read and wrote the
module-level `state` singleton in src/shared/state.js, which the MV3 service
worker never populates and which answered an unpopulated read out of
DEFAULT_STATE in silence. Every previous fix added a loadState() before the
access, and that is what produced the fifth: a load detaches the objects an
in-flight handler is holding.
So the reachability goes rather than a sixth call site.
The background now has its own storage layer, src/background/state.js:
getState() is a detached, normalized per-call read, and updateState() is a
queued read-modify-write whose read is one storage round trip ahead of its
write. Nothing in the background holds an in-memory copy of the profile. The
write is the whole record, and updateState()'s header now names what that costs:
a popup write landing inside that one-round-trip window is reverted.
- Every handler takes one snapshot and answers from it, including the address
it names: activeAddressOf(s) replaced a second, later storage read that
could disagree with the first.
- wallet_switchEthereumChain applies applyChainSwitchFields() (split out of
chainSwitch.js, which keeps the singleton path for the popup) inside
updateState() instead of calling onChainSwitch() on the singleton.
- The remembered site decision is a read-modify-write, not a load-mutate-save
around a prompt the user takes seconds to answer.
- backgroundRefresh() refreshes a private copy of the wallets and applies the
balances that came back by address, so it never publishes an object other
in-flight work holds, and a wallet added or deleted during the round trip
survives its write.
- The transaction attempt takes its chain id and its endpoint from the same
snapshot. They used to come from different moments, so a chain switch
committed in between moved the endpoint under an artifact already verified
against the old chain.
getProvider(rpcUrl, networkId) now REQUIRES the network id and validates it
against networks.js. That closes the cold-worker wrong-chain send at its shape
rather than at one call site: the hint used to default to currentNetwork() off
the unpopulated singleton, so the endpoint was the user's chain and ethers
fixed chainId at 0x1, and the wallet's own verifySignedTx then refused every
non-mainnet dApp send. refreshBalances(), lookupTokenInfo(), scanForAddresses()
and resolveEnsName() carry the id through; balances.js no longer requires
state.js at all.
The prohibition is enforced mechanically, not by review, and it is enforced by
the bundler rather than by a guess at what the bundler does. The table of
modules an entry point's bundle may not contain lives in
script/lib/forbiddenBundleInputs.js — one copy, read by both layers that act on
it — and build.js's assertNoForbiddenInputs() fails the build when esbuild's
metafile reports src/shared/state.js as an input of a background bundle, naming
the import chain from the metafile's own graph. That is the resolution the
shipped bundle was built from, so no specifier syntax, no hop and no resolution
rule can slip past it; Dockerfile:42 runs make build, so it holds in CI.
A background entry point the table does not name fails the build as well. The
five defects were accidents, and so is adding a second worker entry point
without knowing that a table elsewhere needs a line for it: entry points under
src/background/ are prohibited by default and must be listed, rather than
protected only when someone remembers. That prefix is the build's only notion of
"the background", and eslint.config.js scopes the lint rule from the same
constant so the two layers cannot disagree about it.
Every way the table can rot is a failure rather than a quiet pass: a key no
bundled entry point matched, a listed module this build bundled nowhere, and an
entry that lists no modules. The second is what makes a rename of
src/shared/state.js loud instead of silently disarming the check, and it is
stronger than an existsSync() because it also fails when the module is still
there but has dropped out of every bundle. The third is refused at require time,
where the table is defined, because an empty list also empties the lint rule's
forbidden set — one character, and a plain require of the singleton in the
worker was green in make test, make lint and make build alike.
An entry is recorded as checked only once its bundle's inputs are in hand. It
used to be recorded before the output lookup that produces them, so an early
return past that point left both halves of the guarantee satisfied by a bundle
nothing had examined.
What the assertion does NOT cover is a COPY of the singleton at another path: it
is keyed by path, so a copy builds and lints clean. That is stated where the
table lives, with what the residual actually is — a copy carries the singleton's
own guard, so an unloaded read is a loud StateNotLoadedError and defects 1-3
cannot recur silently, but a copy carries loadState() too, so defects 4 and 5
(a stale read several awaits after a load, a load detaching objects an in-flight
handler is mutating) would recur over it in silence.
make check does not run make build, so the assertion is unit tested against
synthetic metafiles in tests/buildForbiddenInputs.test.js: build.js runs its
build() only as a program now and exports the checks. Executing a check in CI
is not testing it — without that file, inverting the condition leaves every
check in this repo green with the singleton back in the worker. Each vacuous
pass above has a case, including the output lookup that finds nothing, the empty
list, the unlisted second entry point, and recordBundledInputs() itself, which
every other case used to hand-seed.
A custom ESLint rule walks the CommonJS require graph from every src/background/
file and reports the same thing in the editor, before a full bundle. It reads
the same table, and it matches specifiers textually, so it is best-effort fast
feedback and not the guarantee — two earlier revisions of it shipped holes (a
template literal, a dynamic import(), a comment inside the call, a directory
resolved through package.json main). Those are covered now and pinned by
tests/backgroundStateLintRule.test.js. Two shapes it does not report are pinned
there as asserted non-reports, so the header's list of its bounds is measured
rather than claimed: a computed specifier (require("../shared/" + "state"),
which esbuild constant-folds into the bundle) and a symlink to the module
(esbuild reports the real path). Each is make lint exit 0 and make build exit 2.
Reading a persisted field of the singleton before any load now throws
StateNotLoadedError instead of serving DEFAULT_STATE.
Test stubs: chrome.storage.local is a serialization boundary, and eight files
stubbed it with an aliasing get, so the object a module held and the object
"storage" held were one object — an assertion could pass on a build that never
wrote anything. Every test that drives real persistence now goes through
tests/support/storageStub.js, which structured-clones in both directions.
closes #320
478 lines
19 KiB
JavaScript
478 lines
19 KiB
JavaScript
// The lost-password route off the delete-wallet screen (issue #312).
|
|
//
|
|
// What is pinned here is that a user who has forgotten the password can
|
|
// still get out — no password is asked for and none is checked — and that
|
|
// the escape hatch destroys exactly the wallet it names and nothing else.
|
|
// The second half is the dangerous one: this is the only control in the
|
|
// product that erases key material without the password that encrypted it,
|
|
// so an off-by-one in the wallet it removes would take a wallet whose
|
|
// owner never asked for it to be touched.
|
|
//
|
|
// The assertions are made against what came back OUT of extension storage,
|
|
// not against the live `state` object. Deleting a wallet in memory and
|
|
// never persisting it looks identical from `state`, and a build that never
|
|
// wrote at all would pass a check that only reads `state` back.
|
|
//
|
|
// That makes the storage stub load-bearing, so it is the shared one from
|
|
// tests/support/storageStub.js, a real store that structured-clones on both
|
|
// `set` and `get`. A stub whose `get` hands back the same object its `set`
|
|
// was given aliases the caller's own array: the test then reads its own
|
|
// in-memory mutation and calls it persistence, and passes against a build
|
|
// that persists nothing
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/324). The aliasing is closed
|
|
// off explicitly by the first test below rather than left as an assumption
|
|
// about `structuredClone`.
|
|
//
|
|
// The view is driven against a minimal DOM stub, in the same shape as
|
|
// tests/exportPrivkey.test.js: the module reads and writes named nodes and
|
|
// needs nothing else from a document.
|
|
|
|
const mockSettingsShow = jest.fn();
|
|
jest.mock("../src/popup/views/settings", () => ({
|
|
show: mockSettingsShow,
|
|
}));
|
|
jest.mock("../src/shared/vault", () => ({
|
|
decryptWithPassword: jest.fn(),
|
|
}));
|
|
|
|
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
|
const { makeStorageStub } = require("./support/storageStub");
|
|
|
|
const VIEW = "delete-wallet-lost-password";
|
|
|
|
// Fixed addresses — never used for anything but these tests.
|
|
const A0 = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
const A1 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
|
const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
|
|
const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
|
|
|
// ------------------------------------------------------------ DOM stub
|
|
|
|
function makeElement(id) {
|
|
const classes = new Set();
|
|
const el = {
|
|
id,
|
|
textContent: "",
|
|
value: "",
|
|
innerHTML: "",
|
|
disabled: false,
|
|
style: {},
|
|
dataset: {},
|
|
listeners: {},
|
|
classList: {
|
|
add: (...names) => names.forEach((n) => classes.add(n)),
|
|
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
|
contains: (n) => classes.has(n),
|
|
toggle: (n, force) => {
|
|
const on = force === undefined ? !classes.has(n) : force;
|
|
if (on) classes.add(n);
|
|
else classes.delete(n);
|
|
return on;
|
|
},
|
|
},
|
|
addEventListener: (name, fn) => {
|
|
el.listeners[name] = el.listeners[name] || [];
|
|
el.listeners[name].push(fn);
|
|
},
|
|
appendChild: () => {},
|
|
remove: () => {},
|
|
querySelectorAll: () => [],
|
|
};
|
|
return el;
|
|
}
|
|
|
|
function makeDocument() {
|
|
const els = new Map();
|
|
return {
|
|
getElementById(id) {
|
|
// The debug banner is created on demand by helpers.js; absent
|
|
// is the state a non-debug, non-testnet popup is in.
|
|
if (id === "debug-banner") return null;
|
|
if (!els.has(id)) els.set(id, makeElement(id));
|
|
return els.get(id);
|
|
},
|
|
createElement: () => makeElement("created"),
|
|
addEventListener: () => {},
|
|
body: { prepend: () => {} },
|
|
};
|
|
}
|
|
|
|
// ------------------------------------------------------------ harness
|
|
|
|
function wallet(name, secret, addresses) {
|
|
return {
|
|
type: "hd",
|
|
name,
|
|
xpub: "xpub-" + name,
|
|
encryptedSecret: secret,
|
|
nextIndex: addresses.length,
|
|
addresses: addresses.map((address) => ({
|
|
address,
|
|
balance: "0.0000",
|
|
tokenBalances: [],
|
|
})),
|
|
};
|
|
}
|
|
|
|
function load() {
|
|
jest.resetModules();
|
|
mockSettingsShow.mockClear();
|
|
|
|
const storage = makeStorageStub();
|
|
const sent = [];
|
|
globalThis.chrome = {
|
|
storage: { local: storage.local },
|
|
runtime: { sendMessage: (msg) => sent.push(msg) },
|
|
};
|
|
globalThis.document = makeDocument();
|
|
|
|
const helpers = require("../src/popup/views/helpers");
|
|
const { state } = require("../src/shared/state");
|
|
const vault = require("../src/shared/vault");
|
|
const deleteWallet = require("../src/popup/views/deleteWallet");
|
|
|
|
state.hasWallet = true;
|
|
state.wallets = [
|
|
wallet("Wallet 1", "secret-one", [A0, A1]),
|
|
wallet("Wallet 2", "secret-two", [B0]),
|
|
wallet("Wallet 3", "secret-three", [C0]),
|
|
];
|
|
state.selectedWallet = 0;
|
|
state.selectedAddress = 0;
|
|
state.activeAddress = A0;
|
|
state.allowedSites = { [A0]: ["a.example"], [B0]: ["b.example"] };
|
|
state.deniedSites = { [B0]: ["c.example"], [C0]: ["d.example"] };
|
|
state.viewStack = ["main", "settings"];
|
|
state.currentView = "settings";
|
|
|
|
const renderWalletList = jest.fn();
|
|
deleteWallet.init({ renderWalletList });
|
|
|
|
return { helpers, state, vault, deleteWallet, storage, sent };
|
|
}
|
|
|
|
function click(id) {
|
|
const el = globalThis.document.getElementById(id);
|
|
return Promise.all((el.listeners.click || []).map((fn) => fn()));
|
|
}
|
|
|
|
function node(id) {
|
|
return globalThis.document.getElementById(id);
|
|
}
|
|
|
|
// The wallets as the extension would read them back on a cold start.
|
|
async function persistedWallets(storage) {
|
|
const result = await storage.get("autistmask");
|
|
return result.autistmask.wallets;
|
|
}
|
|
|
|
// Open the lost-password screen for a wallet, the way the user does.
|
|
async function openLostPassword(deleteWallet, walletIdx) {
|
|
deleteWallet.show(walletIdx);
|
|
await click("btn-delete-wallet-lost-password");
|
|
}
|
|
|
|
// ------------------------------------------------------------ tests
|
|
|
|
// The stub is what every persistence assertion below rests on, so its one
|
|
// dangerous failure mode is closed off first. An aliasing store passes
|
|
// every other test in this file against a build that never writes.
|
|
describe("the storage stub", () => {
|
|
test("does not hand back the object it was given", async () => {
|
|
const storage = makeStorageStub();
|
|
const written = { wallets: [{ name: "Wallet 1" }] };
|
|
|
|
await storage.set({ autistmask: written });
|
|
written.wallets.push({ name: "Wallet 2" });
|
|
written.wallets[0].name = "renamed after the write";
|
|
|
|
const readBack = (await storage.get("autistmask")).autistmask;
|
|
expect(readBack.wallets).toHaveLength(1);
|
|
expect(readBack.wallets[0].name).toBe("Wallet 1");
|
|
|
|
// And the other direction: mutating what came out must not reach
|
|
// back into the store.
|
|
readBack.wallets[0].name = "renamed after the read";
|
|
const again = (await storage.get("autistmask")).autistmask;
|
|
expect(again.wallets[0].name).toBe("Wallet 1");
|
|
});
|
|
});
|
|
|
|
describe("reaching the screen", () => {
|
|
test("the delete screen offers the route", async () => {
|
|
const { deleteWallet, state } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
expect(state.currentView).toBe(VIEW);
|
|
expect(node("delete-wallet-lost-name").textContent).toBe("Wallet 2");
|
|
expect(node("delete-wallet-lost-name-echo").textContent).toBe(
|
|
"Wallet 2",
|
|
);
|
|
});
|
|
|
|
// Both delete screens hang off Settings. Pushing one onto the other
|
|
// would leave Back on the confirm screen popping onto itself.
|
|
test("it does not push the screen it came from", async () => {
|
|
const { deleteWallet, state } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
expect(state.viewStack).toEqual(["main", "settings"]);
|
|
});
|
|
|
|
test("Back returns to the delete screen with its wallet still chosen", async () => {
|
|
const { deleteWallet, state } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
await click("btn-delete-wallet-lost-back");
|
|
|
|
expect(state.currentView).toBe("delete-wallet-confirm");
|
|
expect(node("delete-wallet-name").textContent).toBe("Wallet 2");
|
|
expect(state.viewStack).toEqual(["main", "settings"]);
|
|
|
|
// The confirm screen is usable, not merely on screen: the wallet
|
|
// it holds is the one that was chosen, so its own button does not
|
|
// answer "No wallet selected for deletion."
|
|
node("delete-wallet-password").value = "some password";
|
|
const { decryptWithPassword } = require("../src/shared/vault");
|
|
decryptWithPassword.mockRejectedValue(new Error("nope"));
|
|
await click("btn-delete-wallet-confirm");
|
|
expect(node("delete-wallet-flash").textContent).toBe(
|
|
"That password is incorrect. Please try again.",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("the typed confirmation", () => {
|
|
test("a name that is not the wallet's deletes nothing", async () => {
|
|
const { deleteWallet, state, storage } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
node("delete-wallet-lost-name-input").value = "Wallet 3";
|
|
await click("btn-delete-wallet-lost-confirm");
|
|
|
|
expect(node("delete-wallet-lost-flash").textContent).toBe(
|
|
"That is not the name of this wallet. Type Wallet 2 to confirm.",
|
|
);
|
|
expect(node("delete-wallet-lost-flash").style.visibility).toBe(
|
|
"visible",
|
|
);
|
|
expect(state.wallets.map((w) => w.name)).toEqual([
|
|
"Wallet 1",
|
|
"Wallet 2",
|
|
"Wallet 3",
|
|
]);
|
|
expect(state.currentView).toBe(VIEW);
|
|
// Nothing was destroyed on disk either. Storage is not empty —
|
|
// showView() persists the current screen on the way in — so what
|
|
// is asserted is that all three wallets are still in it.
|
|
const persisted = await persistedWallets(storage);
|
|
expect(persisted.map((w) => w.encryptedSecret)).toEqual([
|
|
"secret-one",
|
|
"secret-two",
|
|
"secret-three",
|
|
]);
|
|
});
|
|
|
|
test("an empty field deletes nothing", async () => {
|
|
const { deleteWallet, state } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
await click("btn-delete-wallet-lost-confirm");
|
|
|
|
expect(node("delete-wallet-lost-flash").style.visibility).toBe(
|
|
"visible",
|
|
);
|
|
expect(state.wallets).toHaveLength(3);
|
|
});
|
|
|
|
// Not a secret and not a password: it asks whether the user knows
|
|
// which wallet they are on. Refusing the name they can plainly read,
|
|
// over letter case, would only teach them to distrust the control.
|
|
test("case and surrounding spaces do not matter", async () => {
|
|
const { deleteWallet, state, storage } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
node("delete-wallet-lost-name-input").value = " wALLet 2 ";
|
|
await click("btn-delete-wallet-lost-confirm");
|
|
|
|
expect(state.wallets.map((w) => w.name)).toEqual([
|
|
"Wallet 1",
|
|
"Wallet 3",
|
|
]);
|
|
expect(await persistedWallets(storage)).toHaveLength(2);
|
|
});
|
|
|
|
// A name with a doubled inner space RENDERS with one — HTML collapses
|
|
// runs of whitespace — so the string the user can see and type is not
|
|
// the string the name is stored as. Comparing the two raw would make
|
|
// this wallet's confirmation impossible to satisfy by any typing at
|
|
// all, wedging the one screen that exists to unwedge people.
|
|
test("a doubled space inside the name is typed back as one", async () => {
|
|
const { deleteWallet, state, storage } = load();
|
|
state.wallets[1].name = "My Wallet";
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
// What the DOM was handed still has both spaces; what the user
|
|
// reads off the screen, and therefore types, has one.
|
|
expect(node("delete-wallet-lost-name").textContent).toBe("My Wallet");
|
|
node("delete-wallet-lost-name-input").value = "My Wallet";
|
|
await click("btn-delete-wallet-lost-confirm");
|
|
|
|
expect(state.wallets.map((w) => w.name)).toEqual([
|
|
"Wallet 1",
|
|
"Wallet 3",
|
|
]);
|
|
const persisted = await persistedWallets(storage);
|
|
expect(persisted.map((w) => w.encryptedSecret)).toEqual([
|
|
"secret-one",
|
|
"secret-three",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("deleting without the password", () => {
|
|
test("no password is asked for and none is checked", async () => {
|
|
const { deleteWallet, vault, storage } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
node("delete-wallet-lost-name-input").value = "Wallet 2";
|
|
await click("btn-delete-wallet-lost-confirm");
|
|
|
|
expect(vault.decryptWithPassword).not.toHaveBeenCalled();
|
|
expect(await persistedWallets(storage)).toHaveLength(2);
|
|
});
|
|
|
|
// The load-bearing assertion of the whole file, and the one that says
|
|
// this control is safe to give a user who cannot prove anything: it
|
|
// removes the wallet it named, and every other wallet survives intact,
|
|
// key material included.
|
|
test("exactly the named wallet is destroyed", async () => {
|
|
const { deleteWallet, storage } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
node("delete-wallet-lost-name-input").value = "Wallet 2";
|
|
await click("btn-delete-wallet-lost-confirm");
|
|
|
|
const wallets = await persistedWallets(storage);
|
|
expect(wallets.map((w) => w.name)).toEqual(["Wallet 1", "Wallet 3"]);
|
|
expect(wallets.map((w) => w.encryptedSecret)).toEqual([
|
|
"secret-one",
|
|
"secret-three",
|
|
]);
|
|
expect(wallets.map((w) => w.xpub)).toEqual([
|
|
"xpub-Wallet 1",
|
|
"xpub-Wallet 3",
|
|
]);
|
|
expect(wallets[0].addresses.map((a) => a.address)).toEqual([A0, A1]);
|
|
expect(wallets[1].addresses.map((a) => a.address)).toEqual([C0]);
|
|
|
|
// The deleted wallet's secret is gone from storage entirely, not
|
|
// merely unreferenced by the wallet list.
|
|
expect(JSON.stringify(storage.read())).not.toContain("secret-two");
|
|
expect(JSON.stringify(storage.read())).not.toContain("xpub-Wallet 2");
|
|
});
|
|
|
|
test("only the deleted wallet's site permissions are dropped", async () => {
|
|
const { deleteWallet, storage } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
node("delete-wallet-lost-name-input").value = "Wallet 2";
|
|
await click("btn-delete-wallet-lost-confirm");
|
|
|
|
const saved = (await storage.get("autistmask")).autistmask;
|
|
expect(saved.allowedSites).toEqual({ [A0]: ["a.example"] });
|
|
expect(saved.deniedSites).toEqual({ [C0]: ["d.example"] });
|
|
});
|
|
|
|
// The route shares finishDelete() with the password route, so the
|
|
// selection repair and the accountsChanged broadcast are the same on
|
|
// both. Deleting a wallet that did not own the active address must
|
|
// leave that address, and the selection, exactly where they were.
|
|
test("a selection in another wallet is left alone", async () => {
|
|
const { deleteWallet, storage, sent } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
node("delete-wallet-lost-name-input").value = "Wallet 2";
|
|
await click("btn-delete-wallet-lost-confirm");
|
|
|
|
const saved = (await storage.get("autistmask")).autistmask;
|
|
expect(saved.activeAddress).toBe(A0);
|
|
expect(saved.selectedWallet).toBe(0);
|
|
expect(saved.selectedAddress).toBe(0);
|
|
expect(sent).toEqual([]);
|
|
// Settings is stubbed, so this is where the route hands over, not
|
|
// where it renders.
|
|
expect(mockSettingsShow).toHaveBeenCalled();
|
|
});
|
|
|
|
test("deleting the wallet holding the active address moves it and says so", async () => {
|
|
const { deleteWallet, storage, sent } = load();
|
|
await openLostPassword(deleteWallet, 0);
|
|
|
|
node("delete-wallet-lost-name-input").value = "Wallet 1";
|
|
await click("btn-delete-wallet-lost-confirm");
|
|
|
|
const saved = (await storage.get("autistmask")).autistmask;
|
|
expect(saved.wallets.map((w) => w.name)).toEqual([
|
|
"Wallet 2",
|
|
"Wallet 3",
|
|
]);
|
|
expect(saved.activeAddress).toBe(B0);
|
|
expect(sent).toEqual([{ type: "AUTISTMASK_ACTIVE_CHANGED" }]);
|
|
});
|
|
|
|
test("deleting the last wallet lands on Welcome with nothing left", async () => {
|
|
const { deleteWallet, state, storage } = load();
|
|
state.wallets = [wallet("Wallet 1", "secret-one", [A0])];
|
|
state.allowedSites = { [A0]: ["a.example"] };
|
|
state.deniedSites = {};
|
|
|
|
await openLostPassword(deleteWallet, 0);
|
|
node("delete-wallet-lost-name-input").value = "Wallet 1";
|
|
await click("btn-delete-wallet-lost-confirm");
|
|
|
|
const saved = (await storage.get("autistmask")).autistmask;
|
|
expect(saved.wallets).toEqual([]);
|
|
expect(saved.hasWallet).toBe(false);
|
|
expect(saved.activeAddress).toBeNull();
|
|
expect(saved.allowedSites).toEqual({});
|
|
expect(state.currentView).toBe("welcome");
|
|
expect(JSON.stringify(storage.read())).not.toContain("secret-one");
|
|
});
|
|
});
|
|
|
|
describe("what the screen leaves behind", () => {
|
|
test("the typed confirmation is wiped when the screen is left", async () => {
|
|
const { helpers, deleteWallet } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
node("delete-wallet-lost-name-input").value = "Wallet 2";
|
|
// The Settings gear, which is not this screen's Back button.
|
|
helpers.showView("settings");
|
|
|
|
expect(node("delete-wallet-lost-name-input").value).toBe("");
|
|
expect(node("delete-wallet-lost-flash").textContent).toBe("");
|
|
expect(node("delete-wallet-lost-flash").style.visibility).toBe(
|
|
"hidden",
|
|
);
|
|
});
|
|
|
|
// Left mid-delete, the screen has to come back usable.
|
|
test("the confirm button is re-enabled on the way out", async () => {
|
|
const { helpers, deleteWallet } = load();
|
|
await openLostPassword(deleteWallet, 1);
|
|
|
|
node("btn-delete-wallet-lost-confirm").disabled = true;
|
|
helpers.showView("settings");
|
|
|
|
expect(node("btn-delete-wallet-lost-confirm").disabled).toBe(false);
|
|
});
|
|
|
|
// A wallet name is not a secret, so the screen is excluded for the
|
|
// other reason: reopening the popup must not land the user on a screen
|
|
// whose button erases key material.
|
|
test("the popup may not reopen onto it", () => {
|
|
expect(RESTORABLE_VIEWS.has(VIEW)).toBe(false);
|
|
expect(RESTORABLE_VIEWS.has("delete-wallet-confirm")).toBe(false);
|
|
});
|
|
});
|