fix: drive background refresh and phishing update from alarms (closes #158)
Some checks failed
check / check (push) Has been cancelled
Some checks failed
check / check (push) Has been cancelled
This commit was merged in pull request #208.
This commit is contained in:
468
tests/alarms.test.js
Normal file
468
tests/alarms.test.js
Normal file
@@ -0,0 +1,468 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user