469 lines
17 KiB
JavaScript
469 lines
17 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.
|
|
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 both recurring jobs", async () => {
|
|
const created = await alarmsMod.ensureRecurringAlarms();
|
|
expect(created).toEqual({ balance: true, phishing: true });
|
|
|
|
const names = alarmsStub.created.map((c) => c.name).sort();
|
|
expect(names).toEqual(
|
|
[
|
|
alarmsMod.BALANCE_REFRESH_ALARM,
|
|
alarmsMod.PHISHING_REFRESH_ALARM,
|
|
].sort(),
|
|
);
|
|
});
|
|
|
|
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("the phishing refresh keeps its 24-hour cadence", async () => {
|
|
await alarmsMod.ensureRecurringAlarms();
|
|
const phishing = alarmsStub.alarms.get(
|
|
alarmsMod.PHISHING_REFRESH_ALARM,
|
|
);
|
|
expect(phishing.periodInMinutes).toBe(24 * 60);
|
|
});
|
|
|
|
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(2);
|
|
|
|
// 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, phishing: false });
|
|
expect(alarmsStub.create).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
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, phishing: false });
|
|
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.PHISHING_REFRESH_ALARM, {
|
|
periodInMinutes: 7 * 24 * 60,
|
|
});
|
|
alarmsStub.create.mockClear();
|
|
|
|
const created = await alarmsMod.ensureRecurringAlarms();
|
|
expect(created.phishing).toBe(true);
|
|
expect(
|
|
alarmsStub.alarms.get(alarmsMod.PHISHING_REFRESH_ALARM)
|
|
.periodInMinutes,
|
|
).toBe(alarmsMod.PHISHING_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, phishing: false });
|
|
expect(alarmsStub.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("handlers are dispatched by alarm name from one listener", () => {
|
|
const balance = jest.fn();
|
|
const phishing = jest.fn();
|
|
expect(
|
|
alarmsMod.registerAlarmHandlers({
|
|
[alarmsMod.BALANCE_REFRESH_ALARM]: balance,
|
|
[alarmsMod.PHISHING_REFRESH_ALARM]: phishing,
|
|
}),
|
|
).toBe(true);
|
|
expect(alarmsStub.listenerCount()).toBe(1);
|
|
|
|
alarmsStub.fire(alarmsMod.BALANCE_REFRESH_ALARM);
|
|
expect(balance).toHaveBeenCalledTimes(1);
|
|
expect(phishing).not.toHaveBeenCalled();
|
|
|
|
alarmsStub.fire(alarmsMod.PHISHING_REFRESH_ALARM);
|
|
expect(phishing).toHaveBeenCalledTimes(1);
|
|
|
|
alarmsStub.fire("some-other-extension-alarm");
|
|
expect(balance).toHaveBeenCalledTimes(1);
|
|
expect(phishing).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, phishing: true });
|
|
expect(firefoxAlarms.created).toHaveLength(2);
|
|
// 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,
|
|
phishing: false,
|
|
});
|
|
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.
|
|
function loadBackground(initialStore = {}) {
|
|
const storageStore = initialStore;
|
|
const alarmsStub = makeAlarmsStub();
|
|
const listeners = { onInstalled: [], onStartup: [] };
|
|
global.chrome = {
|
|
alarms: alarmsStub,
|
|
storage: {
|
|
local: {
|
|
get: async (key) => {
|
|
mockStorageTick();
|
|
return Object.prototype.hasOwnProperty.call(
|
|
storageStore,
|
|
key,
|
|
)
|
|
? { [key]: storageStore[key] }
|
|
: {};
|
|
},
|
|
set: async (items) => {
|
|
mockStorageTick();
|
|
Object.assign(storageStore, items);
|
|
},
|
|
remove: async (key) => {
|
|
delete storageStore[key];
|
|
},
|
|
},
|
|
},
|
|
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() },
|
|
};
|
|
global.fetch = jest.fn(async () => ({
|
|
ok: true,
|
|
json: async () => ({ blacklist: [] }),
|
|
}));
|
|
jest.resetModules();
|
|
require("../src/background/index");
|
|
return { alarmsStub, listeners, store: storageStore };
|
|
}
|
|
|
|
// 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).sort();
|
|
const {
|
|
BALANCE_REFRESH_ALARM,
|
|
PHISHING_REFRESH_ALARM,
|
|
} = require("../src/shared/alarms");
|
|
expect(names).toEqual(
|
|
[BALANCE_REFRESH_ALARM, PHISHING_REFRESH_ALARM].sort(),
|
|
);
|
|
expect(mockSetIntervalCalls).toBe(0);
|
|
});
|
|
|
|
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(2);
|
|
});
|
|
|
|
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(2);
|
|
expect(alarmsStub.created.map((c) => c.name).sort()).toEqual(
|
|
[
|
|
"autistmask-balance-refresh",
|
|
"autistmask-phishing-refresh",
|
|
].sort(),
|
|
);
|
|
});
|
|
});
|
|
|
|
// 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,
|
|
wallets: [
|
|
{ address: "0x0000000000000000000000000000000000000001" },
|
|
],
|
|
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 store = seededStore();
|
|
const { alarmsStub } = loadBackground(store);
|
|
await settle();
|
|
|
|
mockNow += PERIOD_MS;
|
|
store.autistmask.lastBalanceRefresh = mockNow - 10 * 1000;
|
|
alarmsStub.fire(BALANCE_REFRESH_ALARM);
|
|
await settle();
|
|
|
|
expect(mockBalanceRefreshAt).toHaveLength(0);
|
|
});
|
|
});
|