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.
378 lines
14 KiB
JavaScript
378 lines
14 KiB
JavaScript
// A persisted container that is checked while its ENTRIES are dereferenced
|
|
// unchecked (https://git.eeqj.de/sneak/AutistMask/issues/362).
|
|
//
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/311 settled the idiom — floor the
|
|
// container AND its entries, dropping anything that cannot be safely
|
|
// dereferenced — and applied it to trackedTokens and tokenBalances. These are
|
|
// the fields it did not reach.
|
|
//
|
|
// allowedSites is the worst shape in the codebase, and it is what the boot
|
|
// tests below measure: a stored `{"0x…": "notalist"}` passes the gate, renders
|
|
// a WORKING popup, and then throws `base.map is not a function` inside
|
|
// saveState()'s merge — so every save from then on fails while the UI looks
|
|
// entirely healthy and the user goes on operating a wallet that is persisting
|
|
// nothing. A blank popup is at least visibly broken; this is not. So the
|
|
// assertion here is never merely "the popup rendered": it is "the popup
|
|
// rendered AND the write actually landed in storage".
|
|
//
|
|
// Observed at ad6aa7b, with the floors below removed:
|
|
// allowedSites: {"0x…": "notalist"} -> views=["main"], errors=[], and
|
|
// storage.set NEVER called: the stored record kept no schemaVersion, so
|
|
// nothing the user did was persisted.
|
|
// fraudContracts: "0x…" -> renderSendTokenSelect() threw
|
|
// "(state.fraudContracts || []).map is not a function"
|
|
// fraudContracts: [42] -> threw "a.toLowerCase is not a
|
|
// function"
|
|
// selectedToken: 42 (restoring onto address-token) -> views=[], errors=
|
|
// ["tokenId.toLowerCase is not a function"] — a blank popup.
|
|
|
|
const { normalizePersisted } = require("../src/shared/persistedState");
|
|
const { makeStorageStub } = require("./support/storageStub");
|
|
const {
|
|
bootPopup,
|
|
cleanupPopup,
|
|
unversionedValidProfile,
|
|
ADDRESS,
|
|
TOKEN_ADDRESS,
|
|
} = require("./support/popupBoot");
|
|
|
|
// One extension page: a fresh module registry over the given storage. state.js
|
|
// resolves the storage API at require time, so the stub has to be installed
|
|
// before the module is loaded.
|
|
function loadStateModule(storage) {
|
|
jest.resetModules();
|
|
globalThis.chrome = { storage: { local: storage.local } };
|
|
return require("../src/shared/state");
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanupPopup();
|
|
});
|
|
|
|
// ------------------------------------------------------- the floor itself
|
|
|
|
describe("the floor under allowedSites and deniedSites", () => {
|
|
for (const field of ["allowedSites", "deniedSites"]) {
|
|
test(`${field} that is not a record becomes an empty record`, () => {
|
|
for (const bad of ["nope", 42, true, [ADDRESS], null]) {
|
|
expect(normalizePersisted({ [field]: bad })[field]).toEqual({});
|
|
}
|
|
});
|
|
|
|
test(`an ${field} entry whose value is not a hostname list is dropped`, () => {
|
|
for (const bad of ["dapp.example", 42, null, { a: 1 }, true]) {
|
|
expect(
|
|
normalizePersisted({ [field]: { [ADDRESS]: bad } })[field],
|
|
).toEqual({});
|
|
}
|
|
});
|
|
|
|
test(`a hostname that is not text is dropped from an ${field} entry`, () => {
|
|
expect(
|
|
normalizePersisted({
|
|
[field]: { [ADDRESS]: [42, null, "dapp.example", {}] },
|
|
})[field],
|
|
).toEqual({ [ADDRESS]: ["dapp.example"] });
|
|
});
|
|
|
|
test(`a real ${field} map survives, copied not shared`, () => {
|
|
const saved = { [field]: { [ADDRESS]: ["dapp.example"] } };
|
|
|
|
const out = normalizePersisted(saved);
|
|
|
|
expect(out[field]).toEqual(saved[field]);
|
|
expect(out[field]).not.toBe(saved[field]);
|
|
expect(out[field][ADDRESS]).not.toBe(saved[field][ADDRESS]);
|
|
});
|
|
|
|
test(`a good ${field} entry beside a malformed one survives`, () => {
|
|
const out = normalizePersisted({
|
|
[field]: { [ADDRESS]: ["dapp.example"], [TOKEN_ADDRESS]: 42 },
|
|
});
|
|
|
|
expect(out[field]).toEqual({ [ADDRESS]: ["dapp.example"] });
|
|
});
|
|
|
|
test(`a stored own "__proto__" key in ${field} does not become a prototype`, () => {
|
|
// JSON can carry the key, and plain assignment would hand it to
|
|
// the prototype setter — recording no entry and, worse, moving the
|
|
// map's prototype. Same reason networkEndpoints uses
|
|
// defineProperty.
|
|
const saved = JSON.parse(
|
|
'{"' + field + '":{"__proto__":["evil.invalid"]}}',
|
|
);
|
|
|
|
const out = normalizePersisted(saved);
|
|
|
|
expect(Object.getPrototypeOf(out[field])).toBe(Object.prototype);
|
|
expect(Object.keys(out[field])).toEqual(["__proto__"]);
|
|
expect({}.length).toBeUndefined();
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("the floor under fraudContracts", () => {
|
|
test("fraudContracts that is not a list becomes an empty list", () => {
|
|
for (const bad of ["nope", 42, true, { a: 1 }]) {
|
|
expect(
|
|
normalizePersisted({ fraudContracts: bad }).fraudContracts,
|
|
).toEqual([]);
|
|
}
|
|
});
|
|
|
|
test("a fraudContracts entry that is not text is dropped", () => {
|
|
expect(
|
|
normalizePersisted({
|
|
fraudContracts: [42, null, TOKEN_ADDRESS, {}, []],
|
|
}).fraudContracts,
|
|
).toEqual([TOKEN_ADDRESS]);
|
|
});
|
|
|
|
test("a real fraudContracts list survives, copied not shared", () => {
|
|
const saved = { fraudContracts: [TOKEN_ADDRESS] };
|
|
|
|
const out = normalizePersisted(saved);
|
|
|
|
expect(out.fraudContracts).toEqual(saved.fraudContracts);
|
|
expect(out.fraudContracts).not.toBe(saved.fraudContracts);
|
|
});
|
|
});
|
|
|
|
describe("the floor under selectedToken", () => {
|
|
// Found by the sweep for this defect class, not named in the issue: the
|
|
// restore gate in src/popup/viewRouter.js checks truthiness only, and both
|
|
// src/popup/views/addressToken.js and src/popup/views/receive.js then
|
|
// dereference it as text.
|
|
test("a selectedToken that is not text becomes null", () => {
|
|
for (const bad of [42, true, { a: 1 }, [TOKEN_ADDRESS]]) {
|
|
expect(
|
|
normalizePersisted({ selectedToken: bad }).selectedToken,
|
|
).toBeNull();
|
|
}
|
|
});
|
|
|
|
test("a real selectedToken survives; the empty string becomes null", () => {
|
|
expect(
|
|
normalizePersisted({ selectedToken: TOKEN_ADDRESS }).selectedToken,
|
|
).toBe(TOKEN_ADDRESS);
|
|
expect(normalizePersisted({ selectedToken: "ETH" }).selectedToken).toBe(
|
|
"ETH",
|
|
);
|
|
expect(
|
|
normalizePersisted({ selectedToken: "" }).selectedToken,
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ----------------------------------------- what the user actually gets
|
|
|
|
describe("a malformed allowedSites entry", () => {
|
|
const MALFORMED = [
|
|
{ name: "a string", value: "notalist" },
|
|
{ name: "a number", value: 42 },
|
|
{ name: "a record", value: { hostnames: ["dapp.example"] } },
|
|
];
|
|
|
|
for (const { name, value } of MALFORMED) {
|
|
test(`whose value is ${name}: a working popup whose writes persist`, async () => {
|
|
const env = await bootPopup(
|
|
unversionedValidProfile({
|
|
allowedSites: { [ADDRESS]: value },
|
|
}),
|
|
);
|
|
|
|
expect({
|
|
visibleViews: env.visibleViews(),
|
|
errors: env.pageErrors,
|
|
}).toEqual({ visibleViews: ["main"], errors: [] });
|
|
|
|
// The half that matters. A popup that renders and never persists
|
|
// again is worse than one that renders nothing, because nothing
|
|
// tells the user. The version stamp is proof a write landed: it
|
|
// is absent from the stored record until saveState() writes one.
|
|
expect(env.storage.set).toHaveBeenCalled();
|
|
const stored = env.storage.read("autistmask");
|
|
expect(stored.schemaVersion).toBe(1);
|
|
expect(stored.wallets[0].encryptedSecret).toBe(
|
|
"encrypted-secret-1",
|
|
);
|
|
expect(stored.allowedSites).toEqual({});
|
|
});
|
|
}
|
|
|
|
test("the well-formed entries beside it keep working", async () => {
|
|
const env = await bootPopup(
|
|
unversionedValidProfile({
|
|
allowedSites: {
|
|
[ADDRESS]: ["dapp.example"],
|
|
[TOKEN_ADDRESS]: "notalist",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(env.pageErrors).toEqual([]);
|
|
expect(env.storage.read("autistmask").allowedSites).toEqual({
|
|
[ADDRESS]: ["dapp.example"],
|
|
});
|
|
});
|
|
|
|
test("a later save still lands, not just the first", async () => {
|
|
// The failure this closes was in the MERGE, which runs on every save
|
|
// against whatever is in storage at the time. One write landing is not
|
|
// enough: the field has to stay mergeable.
|
|
const storage = makeStorageStub({
|
|
autistmask: unversionedValidProfile({
|
|
allowedSites: { [ADDRESS]: "notalist" },
|
|
}),
|
|
});
|
|
const { state, loadState, saveState } = loadStateModule(storage);
|
|
|
|
await loadState();
|
|
state.theme = "dark";
|
|
await saveState();
|
|
state.utcTimestamps = true;
|
|
await saveState();
|
|
|
|
const stored = storage.read("autistmask");
|
|
expect(stored.theme).toBe("dark");
|
|
expect(stored.utcTimestamps).toBe(true);
|
|
expect(stored.allowedSites).toEqual({});
|
|
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
|
|
});
|
|
});
|
|
|
|
describe("a malformed fraudContracts", () => {
|
|
// The send screen, which is where this one lands: the boot path only
|
|
// reaches fraudContracts through loadHomeTxs(), which catches, so the
|
|
// consequence is an unusable send screen rather than silent data loss.
|
|
function stubSendDocument() {
|
|
const select = { innerHTML: "", children: [] };
|
|
select.appendChild = (child) => select.children.push(child);
|
|
globalThis.document = {
|
|
getElementById: (id) => (id === "send-token" ? select : null),
|
|
createElement: () => ({ value: "", textContent: "" }),
|
|
};
|
|
return select;
|
|
}
|
|
|
|
const HELD = {
|
|
address: TOKEN_ADDRESS,
|
|
symbol: "AAA",
|
|
decimals: 18,
|
|
balance: "12.5",
|
|
holders: 50000,
|
|
};
|
|
|
|
async function sendScreenTokens(fraudContracts) {
|
|
const storage = makeStorageStub({
|
|
autistmask: unversionedValidProfile({ fraudContracts }),
|
|
});
|
|
const { loadState } = loadStateModule(storage);
|
|
await loadState();
|
|
const select = stubSendDocument();
|
|
const { renderSendTokenSelect } = require("../src/popup/views/send");
|
|
|
|
renderSendTokenSelect({ address: ADDRESS, tokenBalances: [HELD] });
|
|
|
|
return select.children.map((opt) => opt.value);
|
|
}
|
|
|
|
for (const bad of ["notalist", 42, { a: 1 }, [42], [null], [{}]]) {
|
|
test(`${JSON.stringify(bad)}: a usable send screen`, async () => {
|
|
await expect(sendScreenTokens(bad)).resolves.toEqual([
|
|
TOKEN_ADDRESS,
|
|
]);
|
|
});
|
|
}
|
|
|
|
test("a real fraud entry beside a malformed one still hides its token", async () => {
|
|
await expect(
|
|
sendScreenTokens([42, TOKEN_ADDRESS.toLowerCase()]),
|
|
).resolves.toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("a malformed selectedToken", () => {
|
|
test("does not blank the popup on restore", async () => {
|
|
const env = await bootPopup(
|
|
unversionedValidProfile({
|
|
currentView: "address-token",
|
|
selectedWallet: 0,
|
|
selectedAddress: 0,
|
|
selectedToken: 42,
|
|
viewStack: ["main", "address"],
|
|
}),
|
|
);
|
|
|
|
expect({
|
|
visibleViews: env.visibleViews(),
|
|
errors: env.pageErrors,
|
|
}).toEqual({ visibleViews: ["main"], errors: [] });
|
|
});
|
|
});
|
|
|
|
// --------------------------------------------- a save that fails is told
|
|
|
|
describe("a save that fails", () => {
|
|
function failingStorage(profile) {
|
|
const storage = makeStorageStub({ autistmask: profile });
|
|
const realSet = storage.local.set;
|
|
storage.local.set = jest.fn(async () => {
|
|
throw new Error("QUOTA_BYTES quota exceeded");
|
|
});
|
|
storage.restoreWrites = () => {
|
|
storage.local.set = realSet;
|
|
};
|
|
return storage;
|
|
}
|
|
|
|
test("is reported, not swallowed by the save queue", async () => {
|
|
const storage = failingStorage(unversionedValidProfile());
|
|
const { state, loadState, saveState, onSaveFailure } =
|
|
loadStateModule(storage);
|
|
const failures = [];
|
|
onSaveFailure((e) => failures.push(String(e && e.message)));
|
|
|
|
await loadState();
|
|
state.theme = "dark";
|
|
// Not awaited, which is how showView() saves on every navigation and
|
|
// how the failure used to disappear entirely.
|
|
saveState();
|
|
for (let i = 0; i < 50; i++) await Promise.resolve();
|
|
|
|
expect(failures).toEqual(["QUOTA_BYTES quota exceeded"]);
|
|
});
|
|
|
|
test("still rejects for a caller that awaits it", async () => {
|
|
const storage = failingStorage(unversionedValidProfile());
|
|
const { state, loadState, saveState, onSaveFailure } =
|
|
loadStateModule(storage);
|
|
onSaveFailure(() => {});
|
|
|
|
await loadState();
|
|
state.theme = "dark";
|
|
|
|
await expect(saveState()).rejects.toThrow("QUOTA_BYTES");
|
|
});
|
|
|
|
test("puts a banner on the popup saying nothing is being saved", async () => {
|
|
const env = await bootPopup(undefined, {
|
|
storage: failingStorage(unversionedValidProfile()),
|
|
});
|
|
|
|
// The popup is still usable — the point is that it no longer looks
|
|
// healthy while silently persisting nothing.
|
|
expect(env.visibleViews()).toEqual(["main"]);
|
|
const banner = env.node("save-failure-banner");
|
|
expect(banner).not.toBeNull();
|
|
expect(banner.textContent).toContain("NOT SAVED");
|
|
expect(banner.textContent).toContain("QUOTA_BYTES quota exceeded");
|
|
});
|
|
|
|
test("no banner appears on a popup whose saves work", async () => {
|
|
const env = await bootPopup(unversionedValidProfile());
|
|
|
|
expect(env.node("save-failure-banner")).toBeNull();
|
|
});
|
|
});
|