The stored profile carried no version, so nothing could tell a record this build wrote from one a later build did, and loadState() coerced scalars while trusting the structure. A wallets that was a string, an array of nulls, or a later schema's wallet records reached the popup and threw on the first dereference: no view, no message, no control, and every dApp call answering a generic -32603 because getActiveAddress() dereferenced the same record. There was no reset or wipe control anywhere in the product, so the only escape was clearing extension storage through browser internals. saveState() and updateState() now both stamp STATE_SCHEMA_VERSION, and every read goes through assertStateUsable() on the raw bytes before normalization can paper over them. Version 1 is the shape that shipped unversioned, so the profile every existing install holds loads normally and is migrated in place by being stamped on the first write; an upgrade shows nobody a wipe prompt for a wallet that is fine. A record this build cannot vouch for is refused instead, and refused all the way: not normalized, not written back, not half-loaded, and not overwritten by a save either. The popup shows a new StateRecovery screen. It names the problem in a sentence, exports the raw record verbatim into a text box on the page (and downloads it where the browser allows one), and offers an erase behind a typed ERASE MY WALLET. Both controls are required: an export with no reset leaves the user stuck, and a reset with no export destroys the only copy of possibly recoverable key material. The Settings gear is hidden while it is up, and showView() is not used to raise it, because both read the state singleton that by then refuses to be read. The background refuses the same record and answers dApps -32001 with a message saying the saved data cannot be read and that nothing was signed or sent, rather than the -32603 it also answers when a signing attempt breaks. networkById() now throws on an id it does not know instead of quietly answering mainnet, which also stops NETWORKS["constructor"] resolving off the prototype chain. Every key test in the gate is an own-property test, because networkId is an object key into networkEndpoints and an unvalidated "__proto__" set that map's prototype instead of an own key, dropping the user's endpoint silently; normalizePersisted() copies endpoint entries with defineProperty for the same reason. The three corrupt blobs from the issue drive the real popup entry point and the real worker in tests; each rendered nothing at all and answered -32603 before this, and the unversioned-but-valid case is tested too. Three test files used fixture wallets the product cannot produce (a bare address string where an address record belongs, a wallet with no address list) and now use whole records. src/popup/restorableViews.js moved to src/shared/restorableViews.js, since persistedState.js requires it and that module is in the background bundle.
283 lines
11 KiB
JavaScript
283 lines
11 KiB
JavaScript
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
|
|
// Address RECORDS, not bare address strings. A stored profile is validated
|
|
// against the schema on every read now (src/shared/stateSchema.js), and a bare
|
|
// string where an address record belongs is one of the shapes that refuses to
|
|
// load — as it should, since every screen dereferences addr.address.
|
|
function oneWallet() {
|
|
return [
|
|
{
|
|
name: "Wallet 1",
|
|
type: "hd",
|
|
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
|
|
},
|
|
];
|
|
}
|
|
|
|
const { makeStorageStub } = require("./support/storageStub");
|
|
|
|
// 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.
|
|
//
|
|
// The stub clones in both directions, as the real chrome.storage.local does —
|
|
// see tests/support/storageStub.js for why an aliasing one made this file
|
|
// assert less than it appears to.
|
|
function loadModuleWith(persisted) {
|
|
jest.resetModules();
|
|
const storage = makeStorageStub(persisted ? { autistmask: persisted } : {});
|
|
global.chrome = { storage };
|
|
return {
|
|
mod: require("../src/shared/state"),
|
|
set: storage.set,
|
|
stored: () => storage.read("autistmask"),
|
|
};
|
|
}
|
|
|
|
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"],
|
|
}),
|
|
});
|
|
});
|
|
});
|