Some checks failed
check / check (push) Has been cancelled
The persisted view stack was restored verbatim. RESTORABLE_VIEWS stopped the popup opening ONTO a view it will not re-render, but nothing kept such a view out of the stack, so Back could land on a screen whose content was deliberately never restored. No secret leaks -- those views are blank precisely because nothing is restored into them; this is a navigation defect. loadState() now truncates the stored stack at the first entry outside RESTORABLE_VIEWS, dropping it and everything above it. Truncating rather than splicing keeps the result a prefix of what was stored, so every surviving entry keeps the Back target it had; splicing would silently re-point the entry above the hole at a different screen. Filtering on load rather than on save is what makes it retroactive for stacks already in storage, and leaves the live in-session stack whole, which it should be. The general case where Back lands on a blank screen even for restorable views, because goBack() re-renders nothing, is separate and tracked at #268.
272 lines
11 KiB
JavaScript
272 lines
11 KiB
JavaScript
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
|
|
function oneWallet() {
|
|
return [{ name: "Wallet 1", type: "hd", addresses: [ADDRESS] }];
|
|
}
|
|
|
|
// state.js resolves the storage API at require time, so the stub has to exist
|
|
// before the module is loaded, and the module registry has to be reset between
|
|
// cases because `state` is a module-level singleton.
|
|
function loadModuleWith(persisted) {
|
|
jest.resetModules();
|
|
const set = jest.fn(async () => {});
|
|
global.chrome = {
|
|
storage: {
|
|
local: {
|
|
get: jest.fn(async () =>
|
|
persisted ? { autistmask: persisted } : {},
|
|
),
|
|
set,
|
|
},
|
|
},
|
|
};
|
|
return { mod: require("../src/shared/state"), set };
|
|
}
|
|
|
|
afterEach(() => {
|
|
delete global.chrome;
|
|
});
|
|
|
|
describe("loadState hasWallet reconciliation", () => {
|
|
// A profile that deleted its last wallet on a build predating the write
|
|
// path fix keeps hasWallet: true forever. It must load as no wallet, which
|
|
// is what sends the popup to the welcome view.
|
|
test("stored hasWallet true with zero wallets loads as no wallet", async () => {
|
|
const { mod } = loadModuleWith({ hasWallet: true, wallets: [] });
|
|
await mod.loadState();
|
|
expect(mod.state.hasWallet).toBe(false);
|
|
});
|
|
|
|
test("stored hasWallet true with a missing wallets key loads as no wallet", async () => {
|
|
const { mod } = loadModuleWith({ hasWallet: true });
|
|
await mod.loadState();
|
|
expect(mod.state.wallets).toEqual([]);
|
|
expect(mod.state.hasWallet).toBe(false);
|
|
});
|
|
|
|
test("stored hasWallet false with one wallet loads as having a wallet", async () => {
|
|
const { mod } = loadModuleWith({
|
|
hasWallet: false,
|
|
wallets: oneWallet(),
|
|
});
|
|
await mod.loadState();
|
|
expect(mod.state.hasWallet).toBe(true);
|
|
});
|
|
|
|
test("absent hasWallet with wallets present loads as having a wallet", async () => {
|
|
const { mod } = loadModuleWith({ wallets: oneWallet() });
|
|
await mod.loadState();
|
|
expect(mod.state.hasWallet).toBe(true);
|
|
});
|
|
|
|
test("consistent stored states are preserved", async () => {
|
|
const withWallet = loadModuleWith({
|
|
hasWallet: true,
|
|
wallets: oneWallet(),
|
|
});
|
|
await withWallet.mod.loadState();
|
|
expect(withWallet.mod.state.hasWallet).toBe(true);
|
|
|
|
const without = loadModuleWith({ hasWallet: false, wallets: [] });
|
|
await without.mod.loadState();
|
|
expect(without.mod.state.hasWallet).toBe(false);
|
|
});
|
|
|
|
test("empty storage leaves the default no-wallet state", async () => {
|
|
const { mod } = loadModuleWith(null);
|
|
await mod.loadState();
|
|
expect(mod.state.hasWallet).toBe(false);
|
|
expect(mod.state.wallets).toEqual([]);
|
|
});
|
|
|
|
// The correction is derived on every load rather than written back, so a
|
|
// load never has a storage side effect.
|
|
test("loadState does not write to storage", async () => {
|
|
const { mod, set } = loadModuleWith({ hasWallet: true, wallets: [] });
|
|
await mod.loadState();
|
|
expect(set).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// Deriving must not disturb the rest of the load.
|
|
test("other persisted fields still load", async () => {
|
|
const { mod } = loadModuleWith({
|
|
hasWallet: false,
|
|
wallets: oneWallet(),
|
|
networkId: "sepolia",
|
|
theme: "dark",
|
|
activeAddress: ADDRESS,
|
|
});
|
|
await mod.loadState();
|
|
expect(mod.state.networkId).toBe("sepolia");
|
|
expect(mod.state.theme).toBe("dark");
|
|
expect(mod.state.activeAddress).toBe(ADDRESS);
|
|
});
|
|
});
|
|
|
|
// The known-symbol spoof filter is a safety filter, so an existing profile
|
|
// stored before the setting existed must load with it on rather than with
|
|
// undefined, which would read as off.
|
|
describe("hideSpoofedSymbols persistence", () => {
|
|
test("defaults to on with empty storage", async () => {
|
|
const { mod } = loadModuleWith(null);
|
|
await mod.loadState();
|
|
expect(mod.state.hideSpoofedSymbols).toBe(true);
|
|
});
|
|
|
|
test("a profile stored without the key loads with it on", async () => {
|
|
const { mod } = loadModuleWith({ wallets: oneWallet() });
|
|
await mod.loadState();
|
|
expect(mod.state.hideSpoofedSymbols).toBe(true);
|
|
});
|
|
|
|
test("an explicit false survives the load", async () => {
|
|
const { mod } = loadModuleWith({
|
|
wallets: oneWallet(),
|
|
hideSpoofedSymbols: false,
|
|
});
|
|
await mod.loadState();
|
|
expect(mod.state.hideSpoofedSymbols).toBe(false);
|
|
});
|
|
|
|
test("saveState persists the flag", async () => {
|
|
const { mod, set } = loadModuleWith(null);
|
|
mod.state.hideSpoofedSymbols = false;
|
|
await mod.saveState();
|
|
expect(set).toHaveBeenCalledWith({
|
|
autistmask: expect.objectContaining({ hideSpoofedSymbols: false }),
|
|
});
|
|
});
|
|
|
|
test("the flag round-trips off through save and load", async () => {
|
|
const first = loadModuleWith(null);
|
|
first.mod.state.hideSpoofedSymbols = false;
|
|
await first.mod.saveState();
|
|
const persisted = first.set.mock.calls[0][0].autistmask;
|
|
|
|
const second = loadModuleWith(persisted);
|
|
await second.mod.loadState();
|
|
expect(second.mod.state.hideSpoofedSymbols).toBe(false);
|
|
});
|
|
|
|
test("the flag round-trips back on through save and load", async () => {
|
|
const first = loadModuleWith(null);
|
|
first.mod.state.hideSpoofedSymbols = true;
|
|
await first.mod.saveState();
|
|
const persisted = first.set.mock.calls[0][0].autistmask;
|
|
|
|
const second = loadModuleWith(persisted);
|
|
await second.mod.loadState();
|
|
expect(second.mod.state.hideSpoofedSymbols).toBe(true);
|
|
});
|
|
});
|
|
|
|
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
|
|
// behind it was restored verbatim, so Back could still walk onto a screen
|
|
// whose content is deliberately never re-rendered — and "show-phrase" has no
|
|
// Back control of its own to leave by. The stack is filtered on load, at the
|
|
// first entry the popup would not render, and everything above it goes too:
|
|
// those entries were reached THROUGH the dropped one.
|
|
describe("restored viewStack is filtered against RESTORABLE_VIEWS", () => {
|
|
const NON_RESTORABLE = ["export-privkey", "show-phrase"];
|
|
|
|
function restoredStack(viewStack, currentView = "settings") {
|
|
return loadModuleWith({
|
|
wallets: oneWallet(),
|
|
currentView,
|
|
viewStack,
|
|
});
|
|
}
|
|
|
|
test("a non-restorable view at the top of the stack is dropped", async () => {
|
|
const { mod } = restoredStack(["main", "address", "export-privkey"]);
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual(["main", "address"]);
|
|
});
|
|
|
|
test("a non-restorable view in the middle truncates the stack there", async () => {
|
|
const { mod } = restoredStack(["main", "show-phrase", "address"]);
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual(["main"]);
|
|
});
|
|
|
|
// Truncating a stack rooted at a non-restorable view leaves nothing, and
|
|
// the restored view still needs somewhere for Back to go.
|
|
test("a non-restorable view at the bottom leaves main to go back to", async () => {
|
|
const { mod } = restoredStack(["export-privkey", "address", "receive"]);
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual(["main"]);
|
|
});
|
|
|
|
test("no restored stack retains a secret-bearing view", async () => {
|
|
for (const view of NON_RESTORABLE) {
|
|
const { mod } = restoredStack(["main", "address", view, "receive"]);
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).not.toContain(view);
|
|
}
|
|
});
|
|
|
|
// The rule is "views the popup will render", not a blocklist of the two
|
|
// secret screens: a name no longer in the set (or never a view at all)
|
|
// has to go the same way.
|
|
test("a name that is not a restorable view at all is dropped", async () => {
|
|
const { mod } = restoredStack(["main", "welcome", "address"]);
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual(["main"]);
|
|
});
|
|
|
|
// Restorable entries are kept verbatim. That they are then unhidden
|
|
// without being re-rendered is a separate defect, tracked in #268; this
|
|
// filter is only about views the popup declined to restore.
|
|
test("an ordinary restorable stack is restored unchanged", async () => {
|
|
const stack = ["main", "address", "address-token"];
|
|
const { mod } = restoredStack(stack);
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual(stack);
|
|
});
|
|
|
|
test("restoring onto main keeps the stack empty", async () => {
|
|
const { mod } = restoredStack(["show-phrase"], "main");
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual([]);
|
|
});
|
|
|
|
// main is not the only view that gets no ["main"] beneath it: restoreView()
|
|
// will not reopen onto a non-restorable view either, so nothing is left for
|
|
// Back to sit under and the stack stays empty.
|
|
test("restoring onto a view the popup will not reopen keeps the stack empty", async () => {
|
|
const { mod } = restoredStack(["export-privkey"], "show-phrase");
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual([]);
|
|
});
|
|
|
|
// Not an array means nothing survives, but the never-empty rule still
|
|
// applies: a corrupt stack must not leave a restored view with no Back
|
|
// target of its own.
|
|
test("a stack that is not an array still gets main beneath a restored view", async () => {
|
|
const { mod } = restoredStack("main");
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual(["main"]);
|
|
});
|
|
|
|
test("a stack that is not an array loads as empty under main", async () => {
|
|
const { mod } = restoredStack({ 0: "main" }, "main");
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual([]);
|
|
});
|
|
|
|
// Filtering belongs on load, not on save: the live in-session stack is
|
|
// legitimate — the user really is one Back away from a screen that is
|
|
// rendered right now — and only a load-side filter also cleans the
|
|
// stacks already sitting in storage.
|
|
test("saveState persists the live stack verbatim", async () => {
|
|
const { mod, set } = loadModuleWith(null);
|
|
mod.state.viewStack = ["main", "address", "export-privkey"];
|
|
await mod.saveState();
|
|
expect(set).toHaveBeenCalledWith({
|
|
autistmask: expect.objectContaining({
|
|
viewStack: ["main", "address", "export-privkey"],
|
|
}),
|
|
});
|
|
});
|
|
});
|