allowedSites was checked as a container while its entries were dereferenced unchecked. A stored {"0x...": "notalist"} passed the state gate and rendered a completely healthy popup, then threw "base.map is not a function" inside saveState()'s per-hostname merge, so every save from that moment on failed and the user went on operating a wallet that was persisting nothing. Measured against the previous head: the popup showed the main view with no page errors, and chrome.storage.local.set was never called at all. deniedSites has the identical shape; fraudContracts the same class with a milder consequence, throwing "(state.fraudContracts || []).map is not a function" on the send screen; and the sweep for the class turned up selectedToken, which is truthiness-gated on restore and then dereferenced as text, blanking the popup outright with "tokenId.toLowerCase is not a function".
All four now get the floor issue 311 settled -- the container AND its entries, with a malformed entry dropped -- through textList() and siteMap() beside the existing tokenRefs() in persistedState.js, rather than a third mechanism. Site-map keys are written with defineProperty for the same reason networkEndpoints' keys are: a stored own "__proto__" key would otherwise be handed to the prototype setter. The background's allowed.includes(hostname) gate is covered by the same floor, where a stored string would have answered a substring match rather than merely throwing.
A save that fails is no longer swallowed. onSaveFailure() in state.js reports every failed save, awaited or not -- the save queue has to attach a rejection handler to keep advancing, which is what made a failure disappear entirely -- and the popup raises a persistent "NOT SAVED" banner naming the reason. The popup's background refresh loop no longer turns a save failure into an unhandled rejection instead of a report. Both halves are needed: the floor only covers the causes it knows about, and storage can still fail for a quota or a revoked permission.
The field-by-field categorisation in the header of stateSchema.js, and its mirror in README.md, were re-verified against the code and moved with the change; the fields left on a loose floor now carry the reason each one is still safe. The popup boot harness moved to tests/support/popupBoot.js so the new tests drive the real entry point rather than duplicating it.
368 lines
15 KiB
JavaScript
368 lines
15 KiB
JavaScript
// 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 boot harness and its DOM stub — built FROM src/popup/index.html, so
|
|
// "which views are visible" is answered against the real element set — live in
|
|
// tests/support/popupBoot.js, since tests/persistedEntryFloors.test.js needs
|
|
// the same boot.
|
|
//
|
|
// 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 {
|
|
bootPopup,
|
|
cleanupPopup,
|
|
unversionedValidProfile,
|
|
ADDRESS,
|
|
TOKEN_ADDRESS,
|
|
} = require("./support/popupBoot");
|
|
|
|
// ------------------------------------------------------------- fixtures
|
|
|
|
// 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,
|
|
},
|
|
},
|
|
];
|
|
|
|
afterEach(cleanupPopup);
|
|
|
|
// --------------------------------------------------------------- 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" },
|
|
]);
|
|
});
|
|
});
|