The stored profile carried no version, so nothing could tell a record this build wrote from one a later build did, and loadState() coerced scalars while trusting the structure. A wallets that was a string, an array of nulls, or a later schema's wallet records reached the popup and threw on the first dereference: no view, no message, no control, and every dApp call answering a generic -32603 because getActiveAddress() dereferenced the same record. There was no reset or wipe control anywhere in the product, so the only escape was clearing extension storage through browser internals. saveState() and updateState() now both stamp STATE_SCHEMA_VERSION, and every read goes through assertStateUsable() on the raw bytes before normalization can paper over them. Version 1 is the shape that shipped unversioned, so the profile every existing install holds loads normally and is migrated in place by being stamped on the first write; an upgrade shows nobody a wipe prompt for a wallet that is fine. A record this build cannot vouch for is refused instead, and refused all the way: not normalized, not written back, not half-loaded, and not overwritten by a save either. The gate covers what nothing downstream can floor. Everything else is normalizePersisted()'s job, and two of those floors were written on truthiness rather than on type, so a truthy value of the wrong type walked straight through and threw on the first dereference — the same blank popup, by a longer route. trackedTokens: "nope" rendered nothing with "Cannot read properties of undefined (reading 'toLowerCase')", and activeAddress: 42 rendered nothing with "address.slice is not a function", for profiles whose wallets were perfectly readable. Both are type checks now, matching what networkEndpoints and viewStack already did in the same file, and an empty list or an empty string still survives. The popup shows a new StateRecovery screen. It names the problem in a sentence, exports the stored record into a text box on the page with no normalization or repair on it (and downloads it where the browser allows), and offers an erase behind a typed ERASE MY WALLET. Both controls are required: an export with no reset leaves the user stuck, and a reset with no export destroys the only copy of possibly recoverable key material. The Settings gear is hidden while it is up, and showView() is not used to raise it, because both read the state singleton that by then refuses to be read. The export is JSON.stringify of the deserialized record, so a value JSON cannot represent — a cycle, or a BigInt, which Firefox's storage can hold — fails the export entirely and leaves erase as the only control; that is now stated where the export is written. The background refuses the same record and answers dApps -32007 with a message saying the saved data cannot be read and that nothing was signed or sent, rather than the -32603 it also answers when a signing attempt breaks. EIP-1474 sets aside -32000..-32099 for implementation-defined server errors but assigns meanings to -32000 through -32006, including -32001 "Resource not found" and the -32002 "Resource unavailable" this wallet already uses for a pending approval; -32007..-32099 are the unassigned ones, and a test pins the code against that table. networkById() now throws on an id it does not know instead of quietly answering mainnet, which also stops NETWORKS["constructor"] resolving off the prototype chain. Every key test in the gate is an own-property test, because networkId is an object key into networkEndpoints and an unvalidated "__proto__" set that map's prototype instead of an own key, dropping the user's endpoint silently; normalizePersisted() copies endpoint entries with defineProperty for the same reason. That own-property discipline is the gate's alone — normalizePersisted() reads the same fields plainly, and the two agree only because a record from storage has been through structuredClone and carries Object.prototype. The three corrupt blobs from the issue drive the real popup entry point and the real worker in tests; each rendered nothing at all and answered -32603 before this, and the unversioned-but-valid case is tested too. Three test files used fixture wallets the product cannot produce (a bare address string where an address record belongs, a wallet with no address list) and now use whole records. src/popup/restorableViews.js moved to src/shared/restorableViews.js, since persistedState.js requires it and that module is in the background bundle.
497 lines
19 KiB
JavaScript
497 lines
19 KiB
JavaScript
// Scheduling for the background context.
|
|
//
|
|
// The Chrome MV3 service worker is terminated after roughly 30 seconds idle,
|
|
// so anything scheduled with setInterval/setTimeout dies with it. These tests
|
|
// pin the recurring jobs to the alarms API and to the re-registration path a
|
|
// revived worker runs.
|
|
|
|
// A controllable clock plus a stubbed balance refresh, so a cadence test can
|
|
// measure the interval between refreshes that actually happened rather than
|
|
// asserting the interval someone intended.
|
|
const { makeStorageStub } = require("./support/storageStub");
|
|
|
|
let mockNow = 0;
|
|
const mockBalanceRefreshAt = [];
|
|
|
|
// jest.resetModules() clears the call record of every jest.fn, and loading the
|
|
// worker is exactly that call — so anything that must be counted across a load
|
|
// is counted here rather than read off a mock.
|
|
let mockSetIntervalCalls = 0;
|
|
|
|
// Extension storage reads do not take a constant amount of time, and that is
|
|
// what makes a guard timed to the alarm period bite: backgroundRefresh()
|
|
// stamps its freshness marker after awaiting loadState(), so any read that is
|
|
// quicker than the previous one puts the next tick inside a guard of exactly
|
|
// one period and the tick is skipped. A simulation with a constant latency
|
|
// would sit exactly on the boundary and hide the bug.
|
|
const MOCK_STORAGE_LATENCIES_MS = [7, 3, 11, 2, 9, 4, 13, 1, 6, 5];
|
|
const MOCK_MAX_STORAGE_LATENCY_MS = Math.max(...MOCK_STORAGE_LATENCIES_MS);
|
|
let mockStorageJitter = false;
|
|
let mockStorageOpCount = 0;
|
|
|
|
function mockStorageTick() {
|
|
if (!mockStorageJitter) return;
|
|
mockNow +=
|
|
MOCK_STORAGE_LATENCIES_MS[
|
|
mockStorageOpCount++ % MOCK_STORAGE_LATENCIES_MS.length
|
|
];
|
|
}
|
|
|
|
jest.mock("../src/shared/balances", () => ({
|
|
refreshBalances: jest.fn(async () => {
|
|
mockBalanceRefreshAt.push(Date.now());
|
|
}),
|
|
getProvider: jest.fn(() => ({})),
|
|
}));
|
|
|
|
function makeAlarmsStub() {
|
|
const alarms = new Map();
|
|
const listeners = [];
|
|
const stub = {
|
|
created: [],
|
|
alarms,
|
|
create: jest.fn((name, info) => {
|
|
stub.created.push({ name, info });
|
|
alarms.set(name, { name, ...info });
|
|
}),
|
|
get: jest.fn(async (name) => alarms.get(name)),
|
|
clear: jest.fn(async (name) => alarms.delete(name)),
|
|
onAlarm: {
|
|
addListener: jest.fn((fn) => listeners.push(fn)),
|
|
},
|
|
fire: (name) => {
|
|
for (const fn of listeners) fn({ name });
|
|
},
|
|
listenerCount: () => listeners.length,
|
|
};
|
|
return stub;
|
|
}
|
|
|
|
describe("alarms module", () => {
|
|
let alarmsStub;
|
|
let alarmsMod;
|
|
|
|
beforeEach(() => {
|
|
jest.resetModules();
|
|
alarmsStub = makeAlarmsStub();
|
|
global.chrome = { alarms: alarmsStub };
|
|
alarmsMod = require("../src/shared/alarms");
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete global.chrome;
|
|
});
|
|
|
|
test("ensureRecurringAlarms schedules the recurring job", async () => {
|
|
const created = await alarmsMod.ensureRecurringAlarms();
|
|
expect(created).toEqual({ balance: true, cleared: [] });
|
|
|
|
const names = alarmsStub.created.map((c) => c.name);
|
|
expect(names).toEqual([alarmsMod.BALANCE_REFRESH_ALARM]);
|
|
});
|
|
|
|
test("the balance refresh keeps its 60-second cadence", async () => {
|
|
await alarmsMod.ensureRecurringAlarms();
|
|
const balance = alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM);
|
|
expect(balance.periodInMinutes).toBe(1);
|
|
});
|
|
|
|
test("a retired job's alarm is cleared, not left running", async () => {
|
|
// The browser holds an alarm until something clears it. Deleting the
|
|
// job from the code is not enough: on every install that ever ran the
|
|
// version which created it, the alarm goes on waking the service
|
|
// worker on its old schedule with nothing to deliver it to.
|
|
for (const name of alarmsMod.OBSOLETE_ALARMS) {
|
|
alarmsStub.create(name, { periodInMinutes: 24 * 60 });
|
|
}
|
|
expect(alarmsMod.OBSOLETE_ALARMS.length).toBeGreaterThan(0);
|
|
|
|
const result = await alarmsMod.ensureRecurringAlarms();
|
|
expect(result.cleared).toEqual(alarmsMod.OBSOLETE_ALARMS);
|
|
for (const name of alarmsMod.OBSOLETE_ALARMS) {
|
|
expect(alarmsStub.alarms.get(name)).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
test("clearing a retired alarm is not re-reported once it is gone", async () => {
|
|
await alarmsMod.ensureRecurringAlarms();
|
|
const again = await alarmsMod.ensureRecurringAlarms();
|
|
expect(again.cleared).toEqual([]);
|
|
});
|
|
|
|
test("no retired name is also a live one", async () => {
|
|
// A name in both lists would be created and then cleared on every
|
|
// start, so the job it schedules would never fire.
|
|
expect(alarmsMod.OBSOLETE_ALARMS).not.toContain(
|
|
alarmsMod.BALANCE_REFRESH_ALARM,
|
|
);
|
|
});
|
|
|
|
test("no period is below the browser-enforced minimum", async () => {
|
|
// A period under one minute is silently clamped by the browser, so a
|
|
// request for one would mean the documented cadence is not the real
|
|
// one. Every period must be a whole minute at or above the minimum.
|
|
await alarmsMod.ensureRecurringAlarms();
|
|
for (const { info } of alarmsStub.created) {
|
|
expect(info.periodInMinutes).toBeGreaterThanOrEqual(
|
|
alarmsMod.MIN_ALARM_PERIOD_MINUTES,
|
|
);
|
|
expect(Number.isInteger(info.periodInMinutes)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("a revived worker does not reset an existing alarm's schedule", async () => {
|
|
await alarmsMod.ensureRecurringAlarms();
|
|
expect(alarmsStub.create).toHaveBeenCalledTimes(1);
|
|
|
|
// Every wake re-runs the startup path. Re-creating an alarm restarts
|
|
// its period, so a busy extension would push the next fire out
|
|
// forever and the job would never run.
|
|
const again = await alarmsMod.ensureRecurringAlarms();
|
|
expect(again).toEqual({ balance: false, cleared: [] });
|
|
expect(alarmsStub.create).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("a missing alarm is re-created on the next start", async () => {
|
|
await alarmsMod.ensureRecurringAlarms();
|
|
await alarmsStub.clear(alarmsMod.BALANCE_REFRESH_ALARM);
|
|
|
|
const again = await alarmsMod.ensureRecurringAlarms();
|
|
expect(again).toEqual({ balance: true, cleared: [] });
|
|
expect(
|
|
alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM),
|
|
).toBeDefined();
|
|
});
|
|
|
|
test("an alarm left over with a stale period is re-created", async () => {
|
|
// An install carries its alarms across an extension update, so a
|
|
// period changed in a new release only ever reaches users if the
|
|
// stale one is reconciled.
|
|
alarmsStub.create(alarmsMod.BALANCE_REFRESH_ALARM, {
|
|
periodInMinutes: 7 * 24 * 60,
|
|
});
|
|
alarmsStub.create.mockClear();
|
|
|
|
const created = await alarmsMod.ensureRecurringAlarms();
|
|
expect(created.balance).toBe(true);
|
|
expect(
|
|
alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM)
|
|
.periodInMinutes,
|
|
).toBe(alarmsMod.BALANCE_REFRESH_PERIOD_MINUTES);
|
|
});
|
|
|
|
test("reconciling a period settles instead of re-creating forever", async () => {
|
|
alarmsStub.create(alarmsMod.BALANCE_REFRESH_ALARM, {
|
|
periodInMinutes: 30,
|
|
});
|
|
await alarmsMod.ensureRecurringAlarms();
|
|
alarmsStub.create.mockClear();
|
|
|
|
const again = await alarmsMod.ensureRecurringAlarms();
|
|
expect(again).toEqual({ balance: false, cleared: [] });
|
|
expect(alarmsStub.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("handlers are dispatched by alarm name from one listener", () => {
|
|
const balance = jest.fn();
|
|
const other = jest.fn();
|
|
expect(
|
|
alarmsMod.registerAlarmHandlers({
|
|
[alarmsMod.BALANCE_REFRESH_ALARM]: balance,
|
|
"autistmask-some-other-job": other,
|
|
}),
|
|
).toBe(true);
|
|
expect(alarmsStub.listenerCount()).toBe(1);
|
|
|
|
alarmsStub.fire(alarmsMod.BALANCE_REFRESH_ALARM);
|
|
expect(balance).toHaveBeenCalledTimes(1);
|
|
expect(other).not.toHaveBeenCalled();
|
|
|
|
alarmsStub.fire("autistmask-some-other-job");
|
|
expect(other).toHaveBeenCalledTimes(1);
|
|
|
|
alarmsStub.fire("an-alarm-with-no-handler");
|
|
expect(balance).toHaveBeenCalledTimes(1);
|
|
expect(other).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("Firefox MV2 gets the same treatment via browser.alarms", async () => {
|
|
// Both targets are built from one bundle. MV2 has a persistent
|
|
// background page, but it takes the alarm path too, so the schedule
|
|
// is the same code on both browsers.
|
|
jest.resetModules();
|
|
const firefoxAlarms = makeAlarmsStub();
|
|
global.browser = { alarms: firefoxAlarms };
|
|
try {
|
|
const mod = require("../src/shared/alarms");
|
|
const created = await mod.ensureRecurringAlarms();
|
|
expect(created).toEqual({ balance: true, cleared: [] });
|
|
expect(firefoxAlarms.created).toHaveLength(1);
|
|
// The Chrome stub must not have been touched.
|
|
expect(alarmsStub.create).not.toHaveBeenCalled();
|
|
} finally {
|
|
delete global.browser;
|
|
}
|
|
});
|
|
|
|
test("a context without the alarms API degrades instead of throwing", async () => {
|
|
jest.resetModules();
|
|
delete global.chrome;
|
|
const mod = require("../src/shared/alarms");
|
|
await expect(mod.ensureRecurringAlarms()).resolves.toEqual({
|
|
balance: false,
|
|
cleared: [],
|
|
});
|
|
expect(mod.registerAlarmHandlers({})).toBe(false);
|
|
});
|
|
});
|
|
|
|
// Loads the background worker against stubbed browser APIs. The returned
|
|
// store is the extension storage the worker sees, so a test can seed wallet
|
|
// state and read back what the worker persisted.
|
|
// The stub clones in both directions, as the real chrome.storage.local does,
|
|
// and carries the latency simulation above on every operation. It used to
|
|
// alias, which for this file meant the worker's in-memory wallets and the
|
|
// "stored" ones were one object — see tests/support/storageStub.js.
|
|
function loadBackground(initialStore = {}) {
|
|
const storage = makeStorageStub(initialStore, mockStorageTick);
|
|
const alarmsStub = makeAlarmsStub();
|
|
const listeners = { onInstalled: [], onStartup: [] };
|
|
global.chrome = {
|
|
alarms: alarmsStub,
|
|
storage,
|
|
runtime: {
|
|
onMessage: { addListener: jest.fn() },
|
|
onConnect: { addListener: jest.fn() },
|
|
onInstalled: {
|
|
addListener: jest.fn((fn) => listeners.onInstalled.push(fn)),
|
|
},
|
|
onStartup: {
|
|
addListener: jest.fn((fn) => listeners.onStartup.push(fn)),
|
|
},
|
|
getURL: (p) => "chrome-extension://test/" + p,
|
|
lastError: null,
|
|
},
|
|
windows: {
|
|
onRemoved: { addListener: jest.fn() },
|
|
create: jest.fn(),
|
|
},
|
|
tabs: { query: jest.fn(), sendMessage: jest.fn() },
|
|
action: { setPopup: jest.fn() },
|
|
};
|
|
// Present so that a startup path which went to the network would be
|
|
// recorded rather than throwing, which is what makes "no request was made"
|
|
// an observation instead of an assumption.
|
|
global.fetch = jest.fn(async () => ({
|
|
ok: true,
|
|
json: async () => ({}),
|
|
}));
|
|
jest.resetModules();
|
|
require("../src/background/index");
|
|
return { alarmsStub, listeners, storage };
|
|
}
|
|
|
|
// Flush the promise chains the startup path and the alarm handlers run on.
|
|
async function settle() {
|
|
for (let i = 0; i < 3; i++) {
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
}
|
|
}
|
|
|
|
describe("background worker scheduling", () => {
|
|
let alarmsStub;
|
|
let timers;
|
|
|
|
beforeEach(() => {
|
|
mockSetIntervalCalls = 0;
|
|
timers = {
|
|
setInterval: jest
|
|
.spyOn(global, "setInterval")
|
|
.mockImplementation(() => {
|
|
mockSetIntervalCalls++;
|
|
return 0;
|
|
}),
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
timers.setInterval.mockRestore();
|
|
delete global.chrome;
|
|
delete global.fetch;
|
|
jest.resetModules();
|
|
});
|
|
|
|
test("startup schedules the recurring jobs as alarms, not timers", async () => {
|
|
alarmsStub = loadBackground().alarmsStub;
|
|
// Let the startup path's promises settle.
|
|
await settle();
|
|
|
|
const names = alarmsStub.created.map((c) => c.name);
|
|
const { BALANCE_REFRESH_ALARM } = require("../src/shared/alarms");
|
|
expect(names).toEqual([BALANCE_REFRESH_ALARM]);
|
|
expect(mockSetIntervalCalls).toBe(0);
|
|
});
|
|
|
|
test("startup contacts nothing", async () => {
|
|
// The phishing blocklist is vendored at build time and there is no
|
|
// other startup fetch, so a worker coming up asks nobody anything.
|
|
// Every wake used to be a candidate for a blocklist download.
|
|
loadBackground();
|
|
await settle();
|
|
expect(global.fetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("an onAlarm listener is installed on startup", async () => {
|
|
alarmsStub = loadBackground().alarmsStub;
|
|
await settle();
|
|
expect(alarmsStub.listenerCount()).toBe(1);
|
|
});
|
|
|
|
test("onInstalled and onStartup both re-establish the schedule", async () => {
|
|
const loaded = loadBackground();
|
|
alarmsStub = loaded.alarmsStub;
|
|
await settle();
|
|
|
|
expect(loaded.listeners.onInstalled).toHaveLength(1);
|
|
expect(loaded.listeners.onStartup).toHaveLength(1);
|
|
|
|
// A browser start after the alarms were dropped must put them back.
|
|
alarmsStub.alarms.clear();
|
|
alarmsStub.created.length = 0;
|
|
loaded.listeners.onStartup[0]();
|
|
await settle();
|
|
expect(alarmsStub.created).toHaveLength(1);
|
|
});
|
|
|
|
test("the install-time listener and the top-level call share one run", async () => {
|
|
// On a fresh install both fire, close enough that both could observe
|
|
// an alarm missing and create it — and a second create restarts the
|
|
// period the first one just set.
|
|
const loaded = loadBackground();
|
|
alarmsStub = loaded.alarmsStub;
|
|
loaded.listeners.onInstalled[0]();
|
|
await settle();
|
|
|
|
expect(alarmsStub.created).toHaveLength(1);
|
|
expect(alarmsStub.created.map((c) => c.name)).toEqual([
|
|
"autistmask-balance-refresh",
|
|
]);
|
|
});
|
|
});
|
|
|
|
// The alarm period alone must set the cadence. A freshness guard timed to the
|
|
// period vetoes the very tick it gates, because the guard is measured from
|
|
// when the last run finished and the alarm fires one run-duration before that.
|
|
// These tests measure the interval between refreshes that actually ran.
|
|
describe("balance refresh steady-state cadence", () => {
|
|
const {
|
|
BALANCE_REFRESH_PERIOD_MINUTES,
|
|
BALANCE_REFRESH_ALARM,
|
|
} = require("../src/shared/alarms");
|
|
const PERIOD_MS = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000;
|
|
|
|
let clockSpy;
|
|
let timerSpy;
|
|
|
|
function seededStore() {
|
|
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: [
|
|
{
|
|
name: "Wallet 1",
|
|
type: "hd",
|
|
addresses: [
|
|
{
|
|
address:
|
|
"0x0000000000000000000000000000000000000001",
|
|
balance: "0",
|
|
tokenBalances: [],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
lastBalanceRefresh: 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockNow = Date.UTC(2026, 0, 1, 0, 0, 0);
|
|
mockBalanceRefreshAt.length = 0;
|
|
mockSetIntervalCalls = 0;
|
|
mockStorageOpCount = 0;
|
|
mockStorageJitter = false;
|
|
clockSpy = jest.spyOn(Date, "now").mockImplementation(() => mockNow);
|
|
timerSpy = jest.spyOn(global, "setInterval").mockImplementation(() => {
|
|
mockSetIntervalCalls++;
|
|
return 0;
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
mockStorageJitter = false;
|
|
clockSpy.mockRestore();
|
|
timerSpy.mockRestore();
|
|
delete global.chrome;
|
|
delete global.fetch;
|
|
jest.resetModules();
|
|
});
|
|
|
|
test("ten alarm ticks produce ten refreshes, one per period", async () => {
|
|
const { alarmsStub } = loadBackground(seededStore());
|
|
await settle();
|
|
mockStorageJitter = true;
|
|
|
|
const TICKS = 10;
|
|
let tickAt = mockNow + PERIOD_MS;
|
|
for (let i = 0; i < TICKS; i++) {
|
|
mockNow = tickAt;
|
|
tickAt += PERIOD_MS;
|
|
alarmsStub.fire(BALANCE_REFRESH_ALARM);
|
|
await settle();
|
|
}
|
|
|
|
// No tick was a no-op. This is the assertion that fails when the guard
|
|
// is timed to the alarm period.
|
|
expect(mockBalanceRefreshAt).toHaveLength(TICKS);
|
|
|
|
// And the observed cadence is one period, not two.
|
|
const intervals = mockBalanceRefreshAt
|
|
.slice(1)
|
|
.map((t, i) => t - mockBalanceRefreshAt[i]);
|
|
for (const interval of intervals) {
|
|
expect(interval).toBeGreaterThanOrEqual(
|
|
PERIOD_MS - MOCK_MAX_STORAGE_LATENCY_MS,
|
|
);
|
|
expect(interval).toBeLessThanOrEqual(
|
|
PERIOD_MS + MOCK_MAX_STORAGE_LATENCY_MS,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("a refresh an open popup just did still suppresses the tick", async () => {
|
|
// The guard's actual job, and the reason it is shortened rather than
|
|
// removed: while the popup is open it refreshes every 10 seconds and
|
|
// stamps the same field, and the background job has nothing to add.
|
|
const { alarmsStub, storage } = loadBackground(seededStore());
|
|
await settle();
|
|
|
|
mockNow += PERIOD_MS;
|
|
// As the open popup's own refresh would leave it: written to storage,
|
|
// not poked into an object the worker happens to share.
|
|
storage.write("autistmask", {
|
|
...storage.read("autistmask"),
|
|
lastBalanceRefresh: mockNow - 10 * 1000,
|
|
});
|
|
alarmsStub.fire(BALANCE_REFRESH_ALARM);
|
|
await settle();
|
|
|
|
expect(mockBalanceRefreshAt).toHaveLength(0);
|
|
});
|
|
});
|