The popup's saveState() and the background's updateState() each read the stored record, change it and write it back, each ordered only within its own context, so a write by one could land between the other's read and write and be lost. Both now hold the browser's lock (navigator.locks) from the read to the write, through withLock() in src/shared/browserApi.js, and the recovery screen's reset takes it too. A page in a Firefox private window gets locks of its own and is not covered. The lock also orders a page's own saves, so both per-context queues are gone. The unit tests get a small lock from a Jest setup file. Model: opus-5-5
416 lines
16 KiB
JavaScript
416 lines
16 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 an origin list is dropped`, () => {
|
|
for (const bad of [
|
|
"https://dapp.example",
|
|
42,
|
|
null,
|
|
{ a: 1 },
|
|
true,
|
|
]) {
|
|
expect(
|
|
normalizePersisted({ [field]: { [ADDRESS]: bad } })[field],
|
|
).toEqual({});
|
|
}
|
|
});
|
|
|
|
test(`an origin that is not text is dropped from an ${field} entry`, () => {
|
|
expect(
|
|
normalizePersisted({
|
|
[field]: {
|
|
[ADDRESS]: [42, null, "https://dapp.example", {}],
|
|
},
|
|
})[field],
|
|
).toEqual({ [ADDRESS]: ["https://dapp.example"] });
|
|
});
|
|
|
|
test(`a real ${field} map survives, copied not shared`, () => {
|
|
const saved = { [field]: { [ADDRESS]: ["https://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]: ["https://dapp.example"],
|
|
[TOKEN_ADDRESS]: 42,
|
|
},
|
|
});
|
|
|
|
expect(out[field]).toEqual({ [ADDRESS]: ["https://dapp.example"] });
|
|
});
|
|
|
|
test(`a stored own "__proto__" key in ${field} is dropped`, () => {
|
|
// JSON can carry the key. It can never be a wallet address, so it
|
|
// grants and denies nothing and goes the way of every other key
|
|
// whose value is unusable; keeping it would only keep a value that
|
|
// saveState()'s merge hands to the prototype setter on the next
|
|
// write.
|
|
const saved = JSON.parse(
|
|
'{"' + field + '":{"__proto__":["https://evil.invalid"]}}',
|
|
);
|
|
|
|
const out = normalizePersisted(saved);
|
|
|
|
expect(Object.getPrototypeOf(out[field])).toBe(Object.prototype);
|
|
expect(Object.keys(out[field])).toEqual([]);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('a stored own "__proto__" key surviving a save', () => {
|
|
// The floor writes map keys with defineProperty; saveState()'s merge is one
|
|
// layer downstream of it and used to write them with plain assignment,
|
|
// which hands "__proto__" to the prototype setter and records no entry.
|
|
// networkEndpoints is where a key that is not a known id is deliberately
|
|
// KEPT, so it is where that undoing shows.
|
|
test("does not move a prototype or vanish from networkEndpoints", async () => {
|
|
const profile = unversionedValidProfile();
|
|
profile.networkEndpoints = JSON.parse(
|
|
'{"__proto__":{"rpcUrl":"https://kept.invalid"}}',
|
|
);
|
|
const storage = makeStorageStub({ autistmask: profile });
|
|
const { state, loadState, saveState } = loadStateModule(storage);
|
|
|
|
await loadState();
|
|
state.theme = "dark";
|
|
await saveState();
|
|
|
|
// Not compared against Object.prototype by identity: the storage stub
|
|
// clones through structuredClone, which builds the result in the host
|
|
// realm, so the two Object.prototypes are different objects.
|
|
const stored = storage.read("autistmask").networkEndpoints;
|
|
expect(Object.getPrototypeOf(stored).rpcUrl).toBeUndefined();
|
|
expect(Object.keys(stored)).toContain("__proto__");
|
|
});
|
|
});
|
|
|
|
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: { origins: ["https://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]: ["https://dapp.example"],
|
|
[TOKEN_ADDRESS]: "notalist",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(env.pageErrors).toEqual([]);
|
|
expect(env.storage.read("autistmask").allowedSites).toEqual({
|
|
[ADDRESS]: ["https://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 when the caller does not await it", 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();
|
|
});
|
|
});
|