fix: version stored state, validate its shape, and give a corrupt blob a way out (closes #311)
Stored state had no version and no structural validation, so a corrupt blob produced a completely blank popup with no message and no recovery control, and made every dApp RPC call from every page answer a generic -32603. There was no reset or wipe control anywhere in the UI. saveState() now stamps a schema version and loadState() validates the shape. A version it does not understand, or a wallets array it cannot parse, lands on a recovery screen that names the problem, offers the stored record verbatim for export, and offers a destructive reset behind a typed confirmation. Unversioned but valid state -- which every existing install has -- migrates in place and keeps working; it is never shown a wipe prompt. A dApp call against unusable state answers -32007, which EIP-1474 leaves unassigned, rather than -32603. networkById() refuses an unknown id loudly instead of returning mainnet, and networkId is validated so a corrupt value cannot be used as an object key. Fields the gate does not refuse are floored by type, container and entries both: a malformed trackedTokens or tokenBalances entry is dropped rather than dereferenced. Verified by an independent sweep of 1152 corrupt blobs producing no blank popup, with the same harness showing 9 blanks against the previous revision.
This commit was merged in pull request #360.
This commit is contained in:
@@ -397,8 +397,23 @@ describe("balance refresh steady-state cadence", () => {
|
||||
return {
|
||||
autistmask: {
|
||||
hasWallet: true,
|
||||
// A whole wallet record, not a bare address: a stored profile
|
||||
// is validated against the schema on every read now
|
||||
// (src/shared/stateSchema.js), and a wallet with no address
|
||||
// list is one of the shapes that refuses to load.
|
||||
wallets: [
|
||||
{ address: "0x0000000000000000000000000000000000000001" },
|
||||
{
|
||||
name: "Wallet 1",
|
||||
type: "hd",
|
||||
addresses: [
|
||||
{
|
||||
address:
|
||||
"0x0000000000000000000000000000000000000001",
|
||||
balance: "0",
|
||||
tokenBalances: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
lastBalanceRefresh: 0,
|
||||
},
|
||||
|
||||
@@ -175,8 +175,22 @@ function loadBackground(options) {
|
||||
}
|
||||
|
||||
const persisted = {
|
||||
// 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 a record
|
||||
// belongs is one of the shapes that refuses to load.
|
||||
wallets: [
|
||||
{ name: "Wallet 1", type: "hd", addresses: [signer.address] },
|
||||
{
|
||||
name: "Wallet 1",
|
||||
type: "hd",
|
||||
addresses: [
|
||||
{
|
||||
address: signer.address,
|
||||
balance: "0",
|
||||
tokenBalances: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
networkId: "mainnet",
|
||||
rpcUrl: "https://rpc.invalid",
|
||||
|
||||
@@ -35,7 +35,7 @@ jest.mock("../src/shared/vault", () => ({
|
||||
decryptWithPassword: jest.fn(),
|
||||
}));
|
||||
|
||||
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
||||
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
const VIEW = "delete-wallet-lost-password";
|
||||
|
||||
@@ -21,7 +21,7 @@ jest.mock("../src/shared/wallet", () => ({
|
||||
getSignerForAddress: jest.fn(() => ({ privateKey: mockPrivateKey })),
|
||||
}));
|
||||
|
||||
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
||||
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
||||
|
||||
const VIEW = "export-privkey";
|
||||
const PASSWORD = "correct horse battery";
|
||||
|
||||
@@ -12,7 +12,7 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const { walletHasRecoveryPhrase } = require("../src/shared/wallet");
|
||||
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
||||
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
||||
|
||||
const SHOW_PHRASE_VIEW = "show-phrase";
|
||||
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
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] }];
|
||||
return [
|
||||
{
|
||||
name: "Wallet 1",
|
||||
type: "hd",
|
||||
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
630
tests/stateRecovery.test.js
Normal file
630
tests/stateRecovery.test.js
Normal file
@@ -0,0 +1,630 @@
|
||||
// A stored profile the popup cannot read must produce a SCREEN, not a blank
|
||||
// popup (https://git.eeqj.de/sneak/AutistMask/issues/311).
|
||||
//
|
||||
// The three corrupt blobs below are the ones the pre-1.0 audit wrote into
|
||||
// storage. Against the build this file was added to, each one rendered nothing
|
||||
// at all — no view, no message, no control — because init() dereferenced
|
||||
// `state.wallets[0].addresses` on a record nothing had validated and threw
|
||||
// before the first showView().
|
||||
//
|
||||
// So the assertions here are deliberately made through the REAL popup entry
|
||||
// point rather than against the recovery view module directly. A recovery
|
||||
// screen that renders perfectly when something calls it, and that nothing
|
||||
// calls, is exactly the defect: what has to be true is that BOOTING the popup
|
||||
// on a bad blob lands on it.
|
||||
//
|
||||
// The DOM stub is built FROM src/popup/index.html — every id in the markup,
|
||||
// with the classes the markup gives it — so "which views are visible" is
|
||||
// answered against the real element set, and a recovery screen with no markup
|
||||
// behind it cannot pass.
|
||||
//
|
||||
// The fourth case is the upgrade one, and it is the case that must NOT reach
|
||||
// the recovery screen: every install in the field has a valid profile with no
|
||||
// version field, and showing those users a wipe prompt would be a worse defect
|
||||
// than the one being fixed. It is migrated in place and keeps working.
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
const POPUP_HTML = fs.readFileSync(
|
||||
path.join(__dirname, "..", "src", "popup", "index.html"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// Fixed address, never used for anything but these tests.
|
||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
// A fixed ERC-20 contract address, same rule.
|
||||
const TOKEN_ADDRESS = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
// ------------------------------------------------------------- fixtures
|
||||
|
||||
// A profile in the shape every install in the field has it: complete, valid,
|
||||
// and carrying no version field, because no build ever wrote one.
|
||||
function unversionedValidProfile() {
|
||||
return {
|
||||
hasWallet: true,
|
||||
wallets: [
|
||||
{
|
||||
type: "hd",
|
||||
name: "Wallet 1",
|
||||
xpub: "xpub-wallet-1",
|
||||
encryptedSecret: "encrypted-secret-1",
|
||||
nextIndex: 1,
|
||||
addresses: [
|
||||
{ address: ADDRESS, balance: "1.5", tokenBalances: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
activeAddress: ADDRESS,
|
||||
networkId: "mainnet",
|
||||
rpcUrl: "https://ethereum-rpc.publicnode.com",
|
||||
blockscoutUrl: "https://eth.blockscout.com/api/v2",
|
||||
allowedSites: { [ADDRESS]: ["dapp.example"] },
|
||||
deniedSites: {},
|
||||
trackedTokens: [],
|
||||
theme: "system",
|
||||
};
|
||||
}
|
||||
|
||||
// The three blobs from the issue, each with the error it produced.
|
||||
const CORRUPT_BLOBS = [
|
||||
{
|
||||
name: "wallets is a string",
|
||||
blob: { hasWallet: true, wallets: ADDRESS, activeAddress: ADDRESS },
|
||||
},
|
||||
{
|
||||
name: "wallets is an array of garbage",
|
||||
blob: {
|
||||
hasWallet: true,
|
||||
wallets: [null, 42, "wallet"],
|
||||
activeAddress: ADDRESS,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "future-schema blob (unknown fields, no version)",
|
||||
blob: {
|
||||
hasWallet: true,
|
||||
// A later schema that renamed the field and moved the key
|
||||
// material, written by a build this one knows nothing about, and
|
||||
// stamped with no version because this build never wrote one.
|
||||
wallets: [
|
||||
{
|
||||
id: "wallet-1",
|
||||
label: "Wallet 1",
|
||||
accounts: [{ addr: ADDRESS, wei: "0x0" }],
|
||||
keyring: { kind: "hd", vault: "…" },
|
||||
},
|
||||
],
|
||||
profileFormat: "am-2",
|
||||
activeAccount: ADDRESS,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ------------------------------------------------------------- DOM stub
|
||||
|
||||
function makeElement(id, className) {
|
||||
const classes = new Set(
|
||||
(className || "").split(/\s+/).filter((name) => name !== ""),
|
||||
);
|
||||
const el = {
|
||||
id,
|
||||
tagName: "DIV",
|
||||
textContent: "",
|
||||
value: "",
|
||||
innerHTML: "",
|
||||
href: "",
|
||||
download: "",
|
||||
disabled: false,
|
||||
style: {},
|
||||
dataset: {},
|
||||
listeners: {},
|
||||
clicked: 0,
|
||||
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);
|
||||
},
|
||||
removeEventListener: () => {},
|
||||
appendChild: () => {},
|
||||
remove: () => {},
|
||||
focus: () => {},
|
||||
select: () => {},
|
||||
setAttribute: (name, value) => {
|
||||
el[name] = value;
|
||||
},
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
click: () => {
|
||||
el.clicked += 1;
|
||||
},
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
// Every id in the markup, with the classes the markup gives it. A view the
|
||||
// popup is supposed to reveal has to exist here, which means it has to exist
|
||||
// in src/popup/index.html.
|
||||
function idsFromHtml(html) {
|
||||
const out = new Map();
|
||||
const tags = html.match(/<[a-zA-Z][^>]*>/g) || [];
|
||||
for (const tag of tags) {
|
||||
const id = /\bid="([^"]+)"/.exec(tag);
|
||||
if (!id) continue;
|
||||
const cls = /\bclass="([^"]*)"/.exec(tag);
|
||||
out.set(id[1], cls ? cls[1] : "");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function makeDocument(html) {
|
||||
const authored = idsFromHtml(html);
|
||||
const els = new Map();
|
||||
for (const [id, className] of authored) {
|
||||
els.set(id, makeElement(id, className));
|
||||
}
|
||||
const created = [];
|
||||
const doc = {
|
||||
listeners: {},
|
||||
getElementById(id) {
|
||||
// Created on demand by updateDebugBanner(); 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(tag) {
|
||||
const el = makeElement("created-" + tag, "");
|
||||
el.tagName = String(tag).toUpperCase();
|
||||
created.push(el);
|
||||
return el;
|
||||
},
|
||||
addEventListener(name, fn) {
|
||||
doc.listeners[name] = doc.listeners[name] || [];
|
||||
doc.listeners[name].push(fn);
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
documentElement: makeElement("html", ""),
|
||||
body: {
|
||||
prepend: () => {},
|
||||
appendChild: () => {},
|
||||
removeChild: () => {},
|
||||
},
|
||||
elements: els,
|
||||
authoredIds: authored,
|
||||
created,
|
||||
};
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- harness
|
||||
|
||||
// Boot the real popup entry point over `stored`, exactly as the browser does:
|
||||
// storage already holds the record, the page loads, DOMContentLoaded fires.
|
||||
async function bootPopup(stored) {
|
||||
jest.resetModules();
|
||||
|
||||
// The two modules that reach the network. Neither is on the path under
|
||||
// test; both would make this suite hit the internet.
|
||||
jest.doMock("../src/shared/prices", () => ({
|
||||
prices: {},
|
||||
refreshPrices: jest.fn(async () => {}),
|
||||
clearPrices: jest.fn(),
|
||||
getPrice: () => null,
|
||||
formatUsd: () => "",
|
||||
formatAddressTotal: () => "",
|
||||
getAddressValue: () => ({ usd: null, partial: false }),
|
||||
getWalletValue: () => ({ usd: null, partial: false }),
|
||||
getTotalValue: () => ({ usd: null, partial: false }),
|
||||
}));
|
||||
jest.doMock("../src/shared/balances", () => ({
|
||||
fetchTokenBalances: jest.fn(async () => []),
|
||||
refreshBalances: jest.fn(async () => {}),
|
||||
lookupTokenInfo: jest.fn(async () => null),
|
||||
getProvider: () => ({}),
|
||||
scanForAddresses: jest.fn(async () => []),
|
||||
}));
|
||||
jest.doMock("../src/shared/transactions", () => ({
|
||||
fetchRecentTransactions: jest.fn(async () => []),
|
||||
filterTransactions: () => [],
|
||||
}));
|
||||
|
||||
const storage = makeStorageStub(
|
||||
stored === undefined ? {} : { autistmask: stored },
|
||||
);
|
||||
const document = makeDocument(POPUP_HTML);
|
||||
const reloads = [];
|
||||
|
||||
globalThis.chrome = {
|
||||
storage: { local: storage.local },
|
||||
runtime: {
|
||||
sendMessage: jest.fn(async () => ({})),
|
||||
getURL: (p) => "chrome-extension://autistmask/" + p,
|
||||
onMessage: { addListener: () => {} },
|
||||
},
|
||||
};
|
||||
globalThis.document = document;
|
||||
globalThis.window = {
|
||||
location: {
|
||||
search: "",
|
||||
href: "chrome-extension://autistmask/src/popup/index.html",
|
||||
reload: () => reloads.push(Date.now()),
|
||||
},
|
||||
matchMedia: () => ({
|
||||
matches: false,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}),
|
||||
addEventListener: () => {},
|
||||
};
|
||||
// The 10s refresh loop init() starts would outlive the test.
|
||||
const realSetInterval = globalThis.setInterval;
|
||||
globalThis.setInterval = () => 0;
|
||||
|
||||
require("../src/popup/index");
|
||||
|
||||
const booted = [];
|
||||
for (const fn of document.listeners.DOMContentLoaded || []) {
|
||||
booted.push(fn());
|
||||
}
|
||||
|
||||
// What the browser console would have shown. A throw out of init() is the
|
||||
// blank popup this issue is about, so it is captured rather than thrown:
|
||||
// the assertion that matters is what ended up on screen.
|
||||
const pageErrors = [];
|
||||
for (const p of booted) {
|
||||
try {
|
||||
await p;
|
||||
} catch (e) {
|
||||
pageErrors.push(String((e && e.message) || e));
|
||||
}
|
||||
}
|
||||
await settle();
|
||||
|
||||
globalThis.setInterval = realSetInterval;
|
||||
|
||||
return {
|
||||
storage,
|
||||
document,
|
||||
pageErrors,
|
||||
reloaded: () => reloads.length,
|
||||
node: (id) => document.getElementById(id),
|
||||
text: (id) => document.getElementById(id).textContent,
|
||||
value: (id) => document.getElementById(id).value,
|
||||
hidden: (id) =>
|
||||
document.getElementById(id).classList.contains("hidden"),
|
||||
click: async (id) => {
|
||||
const el = document.getElementById(id);
|
||||
const fns = el.listeners.click || [];
|
||||
for (const fn of fns) await fn();
|
||||
await settle();
|
||||
},
|
||||
// The view ids whose section is not hidden, as the audit measured them.
|
||||
visibleViews: () => {
|
||||
const out = [];
|
||||
for (const [id, el] of document.elements) {
|
||||
if (!id.startsWith("view-")) continue;
|
||||
if (!el.classList.contains("hidden")) out.push(id.slice(5));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete globalThis.chrome;
|
||||
delete globalThis.document;
|
||||
delete globalThis.window;
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------- tests
|
||||
|
||||
describe("a stored profile the popup cannot read", () => {
|
||||
for (const { name, blob } of CORRUPT_BLOBS) {
|
||||
test(`${name}: the recovery screen, not a blank popup`, async () => {
|
||||
const env = await bootPopup(blob);
|
||||
|
||||
// Asserted together, and in the audit's own shape: a failure here
|
||||
// prints both what was on screen and what the console said, which
|
||||
// is the pair that identifies this defect.
|
||||
expect({
|
||||
visibleViews: env.visibleViews(),
|
||||
errors: env.pageErrors,
|
||||
}).toEqual({ visibleViews: ["state-recovery"], errors: [] });
|
||||
|
||||
// The screen has to NAME the problem. A blank recovery screen is
|
||||
// the same dead end with a border around it.
|
||||
expect(env.text("state-recovery-problem").length).toBeGreaterThan(
|
||||
10,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("the Settings gear is hidden, since every screen behind it reads the profile", async () => {
|
||||
const env = await bootPopup(CORRUPT_BLOBS[0].blob);
|
||||
|
||||
expect(env.hidden("btn-settings")).toBe(true);
|
||||
});
|
||||
|
||||
test("it does not write over the record it could not read", async () => {
|
||||
// The blob is evidence, and possibly the only copy of key material in
|
||||
// a shape a later build could recover. A boot that normalized it back
|
||||
// into storage would destroy exactly that.
|
||||
const env = await bootPopup(CORRUPT_BLOBS[2].blob);
|
||||
|
||||
expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[2].blob);
|
||||
expect(env.storage.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the export on the recovery screen", () => {
|
||||
test("hands back the raw stored record verbatim", async () => {
|
||||
const env = await bootPopup(CORRUPT_BLOBS[2].blob);
|
||||
|
||||
await env.click("btn-state-recovery-export");
|
||||
|
||||
// Shown in the page, which always works, whatever the browser does
|
||||
// with a download from an extension popup.
|
||||
expect(env.hidden("state-recovery-blob")).toBe(false);
|
||||
expect(JSON.parse(env.value("state-recovery-blob"))).toEqual(
|
||||
CORRUPT_BLOBS[2].blob,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the destructive reset on the recovery screen", () => {
|
||||
test("erases nothing without the typed confirmation", async () => {
|
||||
const env = await bootPopup(CORRUPT_BLOBS[0].blob);
|
||||
|
||||
env.node("state-recovery-reset-input").value = "yes";
|
||||
await env.click("btn-state-recovery-reset");
|
||||
|
||||
expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[0].blob);
|
||||
expect(env.text("state-recovery-flash").length).toBeGreaterThan(10);
|
||||
expect(env.reloaded()).toBe(0);
|
||||
});
|
||||
|
||||
test("erases the stored profile once the phrase is typed", async () => {
|
||||
const env = await bootPopup(CORRUPT_BLOBS[0].blob);
|
||||
|
||||
env.node("state-recovery-reset-input").value = "erase my wallet";
|
||||
await env.click("btn-state-recovery-reset");
|
||||
|
||||
expect(env.storage.read("autistmask")).toBeUndefined();
|
||||
expect(env.reloaded()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an unversioned profile that is perfectly valid", () => {
|
||||
// The upgrade case. Every install in the field is in this state, and the
|
||||
// popup must load it, not offer to wipe it.
|
||||
test("boots to the wallet list, not the recovery screen", async () => {
|
||||
const env = await bootPopup(unversionedValidProfile());
|
||||
|
||||
expect(env.visibleViews()).toEqual(["main"]);
|
||||
expect(env.pageErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test("is migrated in place: the version is stamped, the wallet survives", async () => {
|
||||
const env = await bootPopup(unversionedValidProfile());
|
||||
|
||||
const stored = env.storage.read("autistmask");
|
||||
expect(stored.schemaVersion).toBe(1);
|
||||
expect(stored.wallets).toHaveLength(1);
|
||||
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
|
||||
expect(stored.wallets[0].addresses[0].address).toBe(ADDRESS);
|
||||
expect(stored.activeAddress).toBe(ADDRESS);
|
||||
expect(stored.allowedSites).toEqual({ [ADDRESS]: ["dapp.example"] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("a first run with nothing in storage", () => {
|
||||
test("boots to Welcome", async () => {
|
||||
const env = await bootPopup(undefined);
|
||||
|
||||
expect(env.visibleViews()).toEqual(["welcome"]);
|
||||
expect(env.pageErrors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a garbage value in a field the gate does not check", () => {
|
||||
// The gate refuses only what nothing can floor: the wallet list, the
|
||||
// version, the network key. Everything else is normalizePersisted()'s job,
|
||||
// and where that job was written as `saved.x || default` rather than a
|
||||
// type check, a TRUTHY value of the wrong type walked straight through and
|
||||
// threw on the first dereference — the same blank popup this issue is
|
||||
// about, measured the same way. Every row below did, at the head named
|
||||
// against it; none has ever been removed from this list.
|
||||
//
|
||||
// These belong on the floor rather than in the gate: none of these values
|
||||
// carries key material, all have a sane default, and sending a user whose
|
||||
// wallets are perfectly readable to an export-or-erase screen over a
|
||||
// broken token list would destroy more than it saves.
|
||||
//
|
||||
// The CONTAINER and its ELEMENTS are separate defects. Round 2 floored the
|
||||
// containers with Array.isArray(), which left every row whose container is
|
||||
// a well-formed list of malformed entries still blanking the popup: the
|
||||
// dereference is `t.address.toLowerCase()`, one level below the check.
|
||||
const CORRUPT_FIELDS = [
|
||||
// Container shapes. Observed at 2e2ecf9, before the round-2 floor:
|
||||
// trackedTokens: "nope" -> views=[] "Cannot read properties of
|
||||
// undefined (reading 'toLowerCase')"
|
||||
// trackedTokens: 42 -> views=[] "trackedTokens is not iterable"
|
||||
// trackedTokens: {a:1} -> views=[] "trackedTokens is not iterable"
|
||||
// activeAddress: 42 -> views=[] "address.slice is not a
|
||||
// function"
|
||||
// activeAddress: {a:1} -> views=[] "address.slice is not a
|
||||
// function"
|
||||
{ name: "trackedTokens is a string", patch: { trackedTokens: "nope" } },
|
||||
{ name: "trackedTokens is a number", patch: { trackedTokens: 42 } },
|
||||
{
|
||||
name: "trackedTokens is an object",
|
||||
patch: { trackedTokens: { a: 1 } },
|
||||
},
|
||||
{ name: "activeAddress is a number", patch: { activeAddress: 42 } },
|
||||
{
|
||||
name: "activeAddress is an object",
|
||||
patch: { activeAddress: { a: 1 } },
|
||||
},
|
||||
// Element shapes: a list, holding entries that are not token records.
|
||||
// Observed at a10a984, AFTER the container floor:
|
||||
// [1,2] -> views=[] "Cannot read properties of undefined
|
||||
// (reading 'toLowerCase')"
|
||||
// [null] -> views=[] "Cannot read properties of null
|
||||
// (reading 'address')"
|
||||
// [{}] -> views=[] "Cannot read properties of undefined
|
||||
// (reading 'toLowerCase')"
|
||||
// [{address:42}] -> views=[] "t.address.toLowerCase is not a
|
||||
// function"
|
||||
// ["0xAA…"] -> views=[] "Cannot read properties of undefined
|
||||
// (reading 'toLowerCase')"
|
||||
{
|
||||
name: "trackedTokens holds numbers",
|
||||
patch: { trackedTokens: [1, 2] },
|
||||
},
|
||||
{
|
||||
name: "trackedTokens holds null",
|
||||
patch: { trackedTokens: [null] },
|
||||
},
|
||||
{
|
||||
name: "trackedTokens holds a record with no address",
|
||||
patch: { trackedTokens: [{}] },
|
||||
},
|
||||
{
|
||||
name: "trackedTokens holds a record whose address is a number",
|
||||
patch: { trackedTokens: [{ address: 42 }] },
|
||||
},
|
||||
{
|
||||
name: "trackedTokens holds bare address strings",
|
||||
patch: { trackedTokens: [TOKEN_ADDRESS] },
|
||||
},
|
||||
];
|
||||
|
||||
// The same defect one level deeper, inside a wallet the gate accepted.
|
||||
// tokenBalances is written WHOLESALE by refreshBalances(), so the partial
|
||||
// write https://git.eeqj.de/sneak/AutistMask/issues/311 names as the live
|
||||
// cause of a corrupt record lands exactly here. Observed at a10a984:
|
||||
// "x" -> views=[] "Cannot read properties of undefined (reading
|
||||
// 'toLowerCase')" (a string iterates as characters)
|
||||
// [null] -> views=[] "Cannot read properties of null (reading
|
||||
// 'balance')"
|
||||
// [42] -> views=[] "Cannot read properties of undefined (reading
|
||||
// 'toLowerCase')"
|
||||
// 42 -> views=[] "number 42 is not iterable"
|
||||
const CORRUPT_TOKEN_BALANCES = [
|
||||
{ name: "a string", value: "x" },
|
||||
{ name: "a list holding null", value: [null] },
|
||||
{ name: "a list of numbers", value: [42] },
|
||||
{ name: "a number", value: 42 },
|
||||
];
|
||||
|
||||
function profileWithTokenBalances(value) {
|
||||
const profile = unversionedValidProfile();
|
||||
profile.wallets[0].addresses[0].tokenBalances = value;
|
||||
return profile;
|
||||
}
|
||||
|
||||
for (const { name, patch } of CORRUPT_FIELDS) {
|
||||
test(`${name}: a working popup, not a blank one`, async () => {
|
||||
const env = await bootPopup(
|
||||
Object.assign(unversionedValidProfile(), patch),
|
||||
);
|
||||
|
||||
expect({
|
||||
visibleViews: env.visibleViews(),
|
||||
errors: env.pageErrors,
|
||||
}).toEqual({ visibleViews: ["main"], errors: [] });
|
||||
});
|
||||
}
|
||||
|
||||
for (const { name, value } of CORRUPT_TOKEN_BALANCES) {
|
||||
test(`an address whose tokenBalances is ${name}: a working popup, not a blank one`, async () => {
|
||||
const env = await bootPopup(profileWithTokenBalances(value));
|
||||
|
||||
expect({
|
||||
visibleViews: env.visibleViews(),
|
||||
errors: env.pageErrors,
|
||||
}).toEqual({ visibleViews: ["main"], errors: [] });
|
||||
});
|
||||
}
|
||||
|
||||
test("the wallet is intact afterwards, and the bad value is gone", async () => {
|
||||
const env = await bootPopup(
|
||||
Object.assign(unversionedValidProfile(), {
|
||||
trackedTokens: "nope",
|
||||
activeAddress: 42,
|
||||
}),
|
||||
);
|
||||
|
||||
const stored = env.storage.read("autistmask");
|
||||
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
|
||||
expect(stored.trackedTokens).toEqual([]);
|
||||
// Floored to null, then filled in by init()'s auto-default.
|
||||
expect(stored.activeAddress).toBe(ADDRESS);
|
||||
});
|
||||
|
||||
test("an empty activeAddress does not leave the popup with none selected", async () => {
|
||||
// "" is text, so a type check alone lets it through — and init()
|
||||
// auto-selects the first address only on a STRICT null, so it has to
|
||||
// be floored to null rather than kept.
|
||||
const env = await bootPopup(
|
||||
Object.assign(unversionedValidProfile(), { activeAddress: "" }),
|
||||
);
|
||||
|
||||
expect(env.visibleViews()).toEqual(["main"]);
|
||||
expect(env.storage.read("autistmask").activeAddress).toBe(ADDRESS);
|
||||
});
|
||||
|
||||
test("a malformed token entry is dropped, and the well-formed ones beside it survive", async () => {
|
||||
const env = await bootPopup(
|
||||
Object.assign(unversionedValidProfile(), {
|
||||
trackedTokens: [
|
||||
1,
|
||||
null,
|
||||
{},
|
||||
{ address: 42 },
|
||||
TOKEN_ADDRESS,
|
||||
{ address: TOKEN_ADDRESS, symbol: "AAA", decimals: 18 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const stored = env.storage.read("autistmask");
|
||||
expect(stored.trackedTokens).toEqual([
|
||||
{ address: TOKEN_ADDRESS, symbol: "AAA", decimals: 18 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("a malformed tokenBalances entry is dropped, and the wallet and its address survive", async () => {
|
||||
const env = await bootPopup(
|
||||
profileWithTokenBalances([
|
||||
null,
|
||||
42,
|
||||
{ address: TOKEN_ADDRESS, symbol: "AAA", balance: "2.0" },
|
||||
]),
|
||||
);
|
||||
|
||||
const stored = env.storage.read("autistmask");
|
||||
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
|
||||
expect(stored.wallets[0].addresses[0].address).toBe(ADDRESS);
|
||||
expect(stored.wallets[0].addresses[0].tokenBalances).toEqual([
|
||||
{ address: TOKEN_ADDRESS, symbol: "AAA", balance: "2.0" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
423
tests/stateSchema.test.js
Normal file
423
tests/stateSchema.test.js
Normal file
@@ -0,0 +1,423 @@
|
||||
// The stored-profile version stamp and the shape gate in front of it
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/311).
|
||||
//
|
||||
// tests/stateRecovery.test.js covers what the USER sees when a record is
|
||||
// refused. This file covers what is refused and what is not, which is the
|
||||
// half that decides whether an upgrade brick or a false alarm ever happens:
|
||||
// a gate that refuses too much sends a perfectly good wallet to a wipe prompt,
|
||||
// and one that refuses too little is the blank popup again.
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const {
|
||||
STATE_SCHEMA_VERSION,
|
||||
StateUnusableError,
|
||||
assertStateUsable,
|
||||
migrationNeeded,
|
||||
stateProblem,
|
||||
} = require("../src/shared/stateSchema");
|
||||
const {
|
||||
NETWORKS,
|
||||
UnknownNetworkError,
|
||||
isKnownNetworkId,
|
||||
networkById,
|
||||
} = require("../src/shared/networks");
|
||||
const { normalizePersisted } = require("../src/shared/persistedState");
|
||||
const { RESET_PHRASE } = require("../src/popup/views/stateRecovery");
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
function validProfile(extra) {
|
||||
return {
|
||||
hasWallet: true,
|
||||
wallets: [
|
||||
{
|
||||
type: "hd",
|
||||
name: "Wallet 1",
|
||||
xpub: "xpub-wallet-1",
|
||||
encryptedSecret: "encrypted-secret-1",
|
||||
nextIndex: 1,
|
||||
addresses: [
|
||||
{ address: ADDRESS, balance: "1.5", tokenBalances: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
activeAddress: ADDRESS,
|
||||
networkId: "mainnet",
|
||||
...(extra || {}),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete global.chrome;
|
||||
});
|
||||
|
||||
describe("what the gate accepts", () => {
|
||||
test("nothing stored at all is a first run, not a defect", () => {
|
||||
expect(stateProblem(undefined)).toBeNull();
|
||||
expect(stateProblem(null)).toBeNull();
|
||||
expect(stateProblem({})).toBeNull();
|
||||
});
|
||||
|
||||
test("a valid profile with no version field is accepted and migrated", () => {
|
||||
const saved = validProfile();
|
||||
|
||||
expect(stateProblem(saved)).toBeNull();
|
||||
expect(migrationNeeded(saved)).toBe(true);
|
||||
// The migration IS the stamp: version 1 is the shape that shipped
|
||||
// unversioned, so nothing about the record has to change.
|
||||
expect(normalizePersisted(saved).schemaVersion).toBe(
|
||||
STATE_SCHEMA_VERSION,
|
||||
);
|
||||
expect(normalizePersisted(saved).wallets).toEqual(saved.wallets);
|
||||
});
|
||||
|
||||
test("a profile already at the current version needs no migration", () => {
|
||||
const saved = validProfile({ schemaVersion: STATE_SCHEMA_VERSION });
|
||||
|
||||
expect(stateProblem(saved)).toBeNull();
|
||||
expect(migrationNeeded(saved)).toBe(false);
|
||||
});
|
||||
|
||||
test("an empty wallet list is fine", () => {
|
||||
expect(stateProblem({ hasWallet: false, wallets: [] })).toBeNull();
|
||||
});
|
||||
|
||||
test("unknown extra fields alone are not a defect", () => {
|
||||
// Only a change in the MEANING of a stored field is a version bump, so
|
||||
// a field this build does not know must not be a refusal on its own —
|
||||
// otherwise a downgrade would wipe a working wallet.
|
||||
expect(stateProblem(validProfile({ somethingNew: 42 }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the gate refuses", () => {
|
||||
test("a record that is not the record AutistMask stores", () => {
|
||||
expect(stateProblem("wallet")).toMatch(/not the record/);
|
||||
expect(stateProblem([1, 2])).toMatch(/not the record/);
|
||||
});
|
||||
|
||||
test("a version from a newer build, naming both versions", () => {
|
||||
const problem = stateProblem(
|
||||
validProfile({ schemaVersion: STATE_SCHEMA_VERSION + 1 }),
|
||||
);
|
||||
|
||||
expect(problem).toMatch(/newer version/);
|
||||
expect(problem).toContain(String(STATE_SCHEMA_VERSION + 1));
|
||||
expect(problem).toContain(String(STATE_SCHEMA_VERSION));
|
||||
});
|
||||
|
||||
test("a version that is not a version at all", () => {
|
||||
for (const version of ["1", 1.5, 0, -1, null, {}]) {
|
||||
expect(
|
||||
stateProblem(validProfile({ schemaVersion: version })),
|
||||
).toMatch(/schema version/);
|
||||
}
|
||||
});
|
||||
|
||||
test("wallets that is not a list", () => {
|
||||
expect(stateProblem({ wallets: ADDRESS })).toMatch(/not a list/);
|
||||
expect(stateProblem({ wallets: { 0: {} } })).toMatch(/not a list/);
|
||||
});
|
||||
|
||||
test("a wallet that is not a wallet record", () => {
|
||||
expect(stateProblem({ wallets: [null] })).toMatch(/wallet record/);
|
||||
expect(stateProblem({ wallets: [42] })).toMatch(/wallet record/);
|
||||
});
|
||||
|
||||
test("a wallet whose addresses are missing or not records", () => {
|
||||
expect(stateProblem({ wallets: [{ name: "Wallet 1" }] })).toMatch(
|
||||
/no list of addresses/,
|
||||
);
|
||||
expect(stateProblem({ wallets: [{ addresses: [ADDRESS] }] })).toMatch(
|
||||
/not a record/,
|
||||
);
|
||||
expect(stateProblem({ wallets: [{ addresses: [{}] }] })).toMatch(
|
||||
/no address/,
|
||||
);
|
||||
});
|
||||
|
||||
test("the problem names WHICH wallet, counting from one", () => {
|
||||
const problem = stateProblem({
|
||||
wallets: [validProfile().wallets[0], { name: "Wallet 2" }],
|
||||
});
|
||||
|
||||
expect(problem).toContain("Wallet 2");
|
||||
});
|
||||
|
||||
test("assertStateUsable throws the sentence, not a generic error", () => {
|
||||
let thrown = null;
|
||||
try {
|
||||
assertStateUsable({ wallets: ADDRESS });
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(StateUnusableError);
|
||||
expect(thrown.problem).toMatch(/not a list/);
|
||||
expect(thrown.message).toBe(thrown.problem);
|
||||
});
|
||||
});
|
||||
|
||||
describe("networkId, which is used as an object key", () => {
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/311#issuecomment-67478:
|
||||
// state.networkId keys state.networkEndpoints, so a corrupt "__proto__"
|
||||
// sets that map's prototype instead of an own key and the user's endpoint
|
||||
// is silently not recorded.
|
||||
test("a network this build does not know is refused", () => {
|
||||
expect(stateProblem(validProfile({ networkId: "base" }))).toMatch(
|
||||
/does not know/,
|
||||
);
|
||||
});
|
||||
|
||||
test('"__proto__" and "constructor" are refused, not resolved', () => {
|
||||
for (const id of ["__proto__", "constructor", "toString"]) {
|
||||
expect(stateProblem(validProfile({ networkId: id }))).toMatch(
|
||||
/does not know/,
|
||||
);
|
||||
expect(isKnownNetworkId(id)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("the gate reads own properties only", () => {
|
||||
// A record whose PROTOTYPE carries the fields must not be read as
|
||||
// though it carried them itself: that is how a polluted prototype
|
||||
// would decide whether a profile is refused.
|
||||
const inherited = Object.create({
|
||||
schemaVersion: STATE_SCHEMA_VERSION + 99,
|
||||
networkId: "base",
|
||||
wallets: "not a list",
|
||||
});
|
||||
|
||||
expect(stateProblem(inherited)).toBeNull();
|
||||
});
|
||||
|
||||
test("normalizing never turns a stored key into a prototype", () => {
|
||||
// JSON can carry an own "__proto__" key, and plain assignment would
|
||||
// treat it as the prototype setter rather than storing an entry.
|
||||
const saved = JSON.parse(
|
||||
'{"networkId":"mainnet","networkEndpoints":' +
|
||||
'{"__proto__":{"rpcUrl":"https://evil.invalid"}}}',
|
||||
);
|
||||
|
||||
const out = normalizePersisted(saved);
|
||||
|
||||
expect(Object.prototype.hasOwnProperty.call(out, "rpcUrl")).toBe(true);
|
||||
expect(out.rpcUrl).not.toBe("https://evil.invalid");
|
||||
expect(Object.getPrototypeOf(out.networkEndpoints)).toBe(
|
||||
Object.prototype,
|
||||
);
|
||||
expect({}.rpcUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
test("an unknown stored networkId never reaches the endpoint map", () => {
|
||||
// The floor under the gate: normalization alone must not adopt it.
|
||||
const out = normalizePersisted({ networkId: "__proto__" });
|
||||
|
||||
expect(out.networkId).toBe("mainnet");
|
||||
expect(Object.keys(out.networkEndpoints)).toEqual(["mainnet"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the floors under the gate, for fields the gate does not check", () => {
|
||||
// The gate's scope is what nothing can floor. Everything it lets through
|
||||
// is normalizePersisted()'s to make safe, and a floor written as
|
||||
// `saved.x || default` is not one: a truthy value of the wrong type walks
|
||||
// through it and throws on the first dereference, which produced the blank
|
||||
// popup from the issue. Nor is a container check on its own: [1, 2] is a
|
||||
// list, and the dereference is `t.address.toLowerCase()` one level below
|
||||
// it. Container AND entries, therefore — an empty list still survives.
|
||||
const TOKEN = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
test("trackedTokens that is not a list becomes an empty list", () => {
|
||||
for (const bad of ["nope", 42, true, { a: 1 }]) {
|
||||
expect(
|
||||
normalizePersisted({ trackedTokens: bad }).trackedTokens,
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("a trackedTokens entry that is not a token record is dropped", () => {
|
||||
for (const bad of [1, null, {}, { address: 42 }, TOKEN, [], true]) {
|
||||
expect(
|
||||
normalizePersisted({ trackedTokens: [bad] }).trackedTokens,
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("a real trackedTokens list survives, copied not shared", () => {
|
||||
const saved = { trackedTokens: [{ address: ADDRESS, symbol: "AM" }] };
|
||||
|
||||
const out = normalizePersisted(saved);
|
||||
|
||||
expect(out.trackedTokens).toEqual(saved.trackedTokens);
|
||||
expect(out.trackedTokens).not.toBe(saved.trackedTokens);
|
||||
expect(normalizePersisted({ trackedTokens: [] }).trackedTokens).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("a good trackedTokens entry beside a malformed one survives", () => {
|
||||
const good = { address: TOKEN, symbol: "AM", decimals: 18 };
|
||||
|
||||
expect(
|
||||
normalizePersisted({ trackedTokens: [1, null, good, {}] })
|
||||
.trackedTokens,
|
||||
).toEqual([good]);
|
||||
});
|
||||
|
||||
// Below an address record, which the gate walks but does not descend into.
|
||||
// refreshBalances() writes tokenBalances WHOLESALE, so a write that only
|
||||
// partly lands leaves exactly this field malformed.
|
||||
function walletWith(tokenBalances) {
|
||||
return {
|
||||
wallets: [
|
||||
{
|
||||
name: "Wallet 1",
|
||||
addresses: [{ address: ADDRESS, tokenBalances }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function balancesOf(out) {
|
||||
return out.wallets[0].addresses[0].tokenBalances;
|
||||
}
|
||||
|
||||
test("an address's tokenBalances that is not a list becomes an empty list", () => {
|
||||
for (const bad of ["x", 42, true, { a: 1 }, undefined]) {
|
||||
expect(balancesOf(normalizePersisted(walletWith(bad)))).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("a tokenBalances entry that is not a token record is dropped", () => {
|
||||
for (const bad of [null, 42, "x", {}, { address: 42 }]) {
|
||||
expect(balancesOf(normalizePersisted(walletWith([bad])))).toEqual(
|
||||
[],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("a real tokenBalances entry survives, copied not shared", () => {
|
||||
const held = { address: TOKEN, symbol: "AM", balance: "2.0" };
|
||||
const saved = walletWith([held]);
|
||||
|
||||
const out = normalizePersisted(saved);
|
||||
|
||||
expect(balancesOf(out)).toEqual([held]);
|
||||
expect(balancesOf(out)[0]).not.toBe(held);
|
||||
});
|
||||
|
||||
test("activeAddress that is not text becomes null", () => {
|
||||
for (const bad of [42, true, { a: 1 }, [ADDRESS]]) {
|
||||
expect(
|
||||
normalizePersisted({ activeAddress: bad }).activeAddress,
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("a real activeAddress survives; the empty string becomes null", () => {
|
||||
expect(
|
||||
normalizePersisted({ activeAddress: ADDRESS }).activeAddress,
|
||||
).toBe(ADDRESS);
|
||||
// "" is text but it is not an address, and src/popup/index.js
|
||||
// auto-selects the first address only on a STRICT null — so keeping
|
||||
// the empty string would leave the popup with none ever selected.
|
||||
expect(
|
||||
normalizePersisted({ activeAddress: "" }).activeAddress,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("networkById on an unknown id", () => {
|
||||
test("throws instead of quietly answering mainnet", () => {
|
||||
expect(() => networkById("base")).toThrow(UnknownNetworkError);
|
||||
expect(() => networkById(undefined)).toThrow(UnknownNetworkError);
|
||||
// Both of these used to answer with something truthy off the
|
||||
// prototype chain rather than with a network.
|
||||
expect(() => networkById("constructor")).toThrow(UnknownNetworkError);
|
||||
expect(() => networkById("__proto__")).toThrow(UnknownNetworkError);
|
||||
});
|
||||
|
||||
test("still answers every network it does know", () => {
|
||||
for (const id of Object.keys(NETWORKS)) {
|
||||
expect(networkById(id).id).toBe(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the version stamp on the way out", () => {
|
||||
function loadStateModule(persisted) {
|
||||
jest.resetModules();
|
||||
const storage = makeStorageStub(
|
||||
persisted ? { autistmask: persisted } : {},
|
||||
);
|
||||
global.chrome = { storage };
|
||||
return { storage, mod: require("../src/shared/state") };
|
||||
}
|
||||
|
||||
test("the popup stamps it on a profile that had none", async () => {
|
||||
const { storage, mod } = loadStateModule(validProfile());
|
||||
|
||||
await mod.loadState();
|
||||
await mod.saveState();
|
||||
|
||||
expect(storage.read("autistmask").schemaVersion).toBe(
|
||||
STATE_SCHEMA_VERSION,
|
||||
);
|
||||
});
|
||||
|
||||
test("the background stamps it on a profile that had none", async () => {
|
||||
jest.resetModules();
|
||||
const storage = makeStorageStub({ autistmask: validProfile() });
|
||||
global.chrome = { storage };
|
||||
const { updateState } = require("../src/background/state");
|
||||
|
||||
await updateState((s) => {
|
||||
s.lastBalanceRefresh = 1;
|
||||
});
|
||||
|
||||
expect(storage.read("autistmask").schemaVersion).toBe(
|
||||
STATE_SCHEMA_VERSION,
|
||||
);
|
||||
});
|
||||
|
||||
test("a save refuses to write over a record it cannot read", async () => {
|
||||
// Another context — a newer build, or something else entirely — wrote
|
||||
// a record this one does not understand while this page was open. That
|
||||
// record is the only copy of whatever it holds, and normalizing it
|
||||
// back into storage would destroy it.
|
||||
const { storage, mod } = loadStateModule(validProfile());
|
||||
await mod.loadState();
|
||||
|
||||
const hostile = { schemaVersion: STATE_SCHEMA_VERSION + 1 };
|
||||
storage.write("autistmask", hostile);
|
||||
|
||||
// By name rather than by constructor: jest.resetModules() above gives
|
||||
// the module under test its own copy of the error class, so instanceof
|
||||
// across that boundary would be comparing two identical classes.
|
||||
const thrown = await mod.saveState().then(
|
||||
() => null,
|
||||
(e) => e,
|
||||
);
|
||||
expect(thrown && thrown.name).toBe("StateUnusableError");
|
||||
expect(thrown.problem).toMatch(/newer version/);
|
||||
expect(storage.read("autistmask")).toEqual(hostile);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the typed confirmation phrase", () => {
|
||||
test("the markup asks for the phrase the code checks", () => {
|
||||
// The button is behind a phrase typed by hand. A screen that asks for
|
||||
// one phrase while the code compares another is an exit that cannot be
|
||||
// taken, on the one screen that exists to be an exit.
|
||||
const html = fs.readFileSync(
|
||||
path.join(__dirname, "..", "src", "popup", "index.html"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(html).toContain(RESET_PHRASE);
|
||||
});
|
||||
});
|
||||
203
tests/stateUnusableRpc.test.js
Normal file
203
tests/stateUnusableRpc.test.js
Normal file
@@ -0,0 +1,203 @@
|
||||
// What a dApp is told when the wallet's stored profile cannot be read
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/311).
|
||||
//
|
||||
// The popup is not the only casualty of a bad blob. getActiveAddress()
|
||||
// dereferences the stored wallet list on nearly every method, so against the
|
||||
// build this file was added to, EVERY request from EVERY page came back as
|
||||
// -32603 "AutistMask could not complete this request because of an internal
|
||||
// error" — the code the wallet also answers when a signing attempt blows up,
|
||||
// with nothing in it to tell the page or the user what is actually wrong or
|
||||
// what to do about it.
|
||||
//
|
||||
// So what is pinned here is that the answer is SPECIFIC: its own code, and a
|
||||
// message that says the saved data cannot be read, that nothing was signed or
|
||||
// sent, and where to go to fix it.
|
||||
//
|
||||
// Same cold-worker shape as tests/coldWorkerChainId.test.js: the real state
|
||||
// modules, over a storage stub, with no loadState() of the test's own — the
|
||||
// handler has to reach storage by itself, as a worker revived by the page's
|
||||
// own message does.
|
||||
|
||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
// The generic answer, quoted rather than imported: this file's whole point is
|
||||
// that the state-unusable path stopped using it.
|
||||
const GENERIC_INTERNAL_ERROR_CODE = -32603;
|
||||
|
||||
// EIP-1474's assigned non-standard codes, verbatim. The spec sets aside
|
||||
// -32000..-32099 for implementation-defined server errors but hands out
|
||||
// meanings for the first seven, so those are exactly the codes this condition
|
||||
// may NOT take: a page reading -32001 is entitled to read "Resource not
|
||||
// found". -32002 is in this table AND in use here, for a pending approval.
|
||||
const EIP_1474_ASSIGNED = {
|
||||
"-32000": "Invalid input",
|
||||
"-32001": "Resource not found",
|
||||
"-32002": "Resource unavailable",
|
||||
"-32003": "Transaction rejected",
|
||||
"-32004": "Method not supported",
|
||||
"-32005": "Limit exceeded",
|
||||
"-32006": "JSON-RPC version not supported",
|
||||
};
|
||||
|
||||
// The three blobs from the issue.
|
||||
const CORRUPT_BLOBS = [
|
||||
{
|
||||
name: "wallets is a string",
|
||||
blob: { hasWallet: true, wallets: ADDRESS },
|
||||
},
|
||||
{
|
||||
name: "wallets is an array of garbage",
|
||||
blob: { hasWallet: true, wallets: [null, 42, "wallet"] },
|
||||
},
|
||||
{
|
||||
name: "future-schema blob (unknown fields, no version)",
|
||||
blob: {
|
||||
hasWallet: true,
|
||||
wallets: [{ id: "wallet-1", accounts: [{ addr: ADDRESS }] }],
|
||||
profileFormat: "am-2",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete global.chrome;
|
||||
});
|
||||
|
||||
function loadColdWorker(stored) {
|
||||
jest.resetModules();
|
||||
|
||||
jest.doMock("../src/shared/balances", () => ({
|
||||
getProvider: () => ({}),
|
||||
refreshBalances: jest.fn(async () => {}),
|
||||
}));
|
||||
jest.doMock("../src/shared/phishingDomains", () => ({
|
||||
isPhishingDomain: () => false,
|
||||
}));
|
||||
jest.doMock("../src/shared/alarms", () => ({
|
||||
BALANCE_REFRESH_ALARM: "balance",
|
||||
BALANCE_REFRESH_PERIOD_MINUTES: 1,
|
||||
ensureRecurringAlarms: jest.fn(async () => {}),
|
||||
registerAlarmHandlers: jest.fn(),
|
||||
}));
|
||||
|
||||
const store = { autistmask: structuredClone(stored) };
|
||||
let messageListener = null;
|
||||
const set = jest.fn(async (items) => {
|
||||
store.autistmask = structuredClone(items.autistmask);
|
||||
});
|
||||
|
||||
global.chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: jest.fn(async () => structuredClone(store)),
|
||||
set,
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
getURL: (p) => "chrome-extension://autistmask/" + p,
|
||||
onMessage: {
|
||||
addListener: (fn) => {
|
||||
messageListener = fn;
|
||||
},
|
||||
},
|
||||
onConnect: { addListener: () => {} },
|
||||
lastError: null,
|
||||
},
|
||||
windows: {
|
||||
getLastFocused: (cb) => cb(null),
|
||||
create: (options, cb) => cb({ id: 1 }),
|
||||
remove: (id, cb) => {
|
||||
if (cb) cb();
|
||||
},
|
||||
onRemoved: { addListener: () => {} },
|
||||
},
|
||||
tabs: {
|
||||
query: (queryInfo, cb) => cb([{ id: 1 }]),
|
||||
sendMessage: (tabId, message, cb) => {
|
||||
if (cb) cb();
|
||||
},
|
||||
},
|
||||
action: { setPopup: () => {} },
|
||||
};
|
||||
|
||||
require("../src/background/index");
|
||||
|
||||
async function rpc(method, params) {
|
||||
let result = null;
|
||||
messageListener(
|
||||
{ type: "AUTISTMASK_RPC", method, params: params || [] },
|
||||
{ origin: CONNECTED_ORIGIN },
|
||||
(r) => {
|
||||
result = r;
|
||||
},
|
||||
);
|
||||
await settle();
|
||||
return result;
|
||||
}
|
||||
|
||||
return { rpc, persisted: () => store.autistmask, storageSet: set };
|
||||
}
|
||||
|
||||
// Every method a page can reach that has to consult the profile.
|
||||
const METHODS = [
|
||||
"eth_accounts",
|
||||
"eth_requestAccounts",
|
||||
"eth_chainId",
|
||||
"personal_sign",
|
||||
"eth_sendTransaction",
|
||||
];
|
||||
|
||||
describe("a dApp call against a profile the wallet cannot read", () => {
|
||||
for (const { name, blob } of CORRUPT_BLOBS) {
|
||||
test(`${name}: a specific error, not the generic internal one`, async () => {
|
||||
const bg = loadColdWorker(blob);
|
||||
|
||||
const answer = await bg.rpc("eth_accounts");
|
||||
|
||||
expect(answer.error).toBeDefined();
|
||||
expect(answer.error.code).not.toBe(GENERIC_INTERNAL_ERROR_CODE);
|
||||
// The message has to say what is wrong, that nothing was sent,
|
||||
// and where to go. "Internal error" says none of the three.
|
||||
expect(answer.error.message).toMatch(/saved data/i);
|
||||
expect(answer.error.message).toMatch(/nothing was/i);
|
||||
expect(answer.error.message).toMatch(/AutistMask/);
|
||||
});
|
||||
}
|
||||
|
||||
test("every method that consults the profile answers the same way", async () => {
|
||||
const bg = loadColdWorker(CORRUPT_BLOBS[0].blob);
|
||||
|
||||
const codes = new Set();
|
||||
for (const method of METHODS) {
|
||||
const answer = await bg.rpc(method, ["0x00", ADDRESS]);
|
||||
expect(answer.error).toBeDefined();
|
||||
codes.add(answer.error.code);
|
||||
}
|
||||
|
||||
// One code for the condition, whatever the method was.
|
||||
expect(codes.size).toBe(1);
|
||||
expect(codes.has(GENERIC_INTERNAL_ERROR_CODE)).toBe(false);
|
||||
|
||||
// And it is a code EIP-1474 has not already given a meaning to, so a
|
||||
// page reading it by the spec's table is not told something false.
|
||||
const code = [...codes][0];
|
||||
expect(EIP_1474_ASSIGNED[String(code)]).toBeUndefined();
|
||||
expect(code).toBeLessThanOrEqual(-32007);
|
||||
expect(code).toBeGreaterThanOrEqual(-32099);
|
||||
});
|
||||
|
||||
test("it does not write over the record it could not read", async () => {
|
||||
const bg = loadColdWorker(CORRUPT_BLOBS[1].blob);
|
||||
|
||||
await bg.rpc("eth_accounts");
|
||||
await bg.rpc("eth_chainId");
|
||||
|
||||
expect(bg.storageSet).not.toHaveBeenCalled();
|
||||
expect(bg.persisted()).toEqual(CORRUPT_BLOBS[1].blob);
|
||||
});
|
||||
});
|
||||
@@ -107,7 +107,7 @@ global.chrome = { storage };
|
||||
|
||||
const txStatus = require("../src/popup/views/txStatus");
|
||||
const { state } = require("../src/shared/state");
|
||||
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
||||
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
||||
|
||||
const TX_HASH =
|
||||
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
|
||||
|
||||
Reference in New Issue
Block a user