Compare commits

...

1 Commits

Author SHA1 Message Date
clawbot
cafffe5ab9 fix: drive background refresh and phishing update from alarms (closes #158)
All checks were successful
check / check (push) Successful in 30s
The Chrome MV3 service worker is terminated after roughly 30 seconds idle,
which destroyed both recurring jobs: the 60-second balance refresh and the
24-hour phishing blocklist refresh were setInterval schedules, so in
practice each ran only while the worker happened to be alive. The phishing
delta was persisted to localStorage, which does not exist in a service
worker, so on Chrome it was never persisted at all.

Both jobs now run off the extension alarms API in the new
src/shared/alarms.js: the browser holds the schedule and wakes the worker to
deliver it. The balance refresh is one minute and the phishing refresh is
1440 minutes, both whole minutes at or above the one-minute minimum, so
neither is silently clamped. Alarms are created only when missing, because
creating one restarts its period and the startup path runs on every wake.

The phishing delta and the timestamp of the fetch that produced it now live
in extension storage, and updatePhishingList() reloads that record before
deciding whether a fetch is due. A revived worker therefore neither
re-fetches on every wake nor sleeps through an overdue update. The 256 KiB
cap covers the whole record: an oversized delta is dropped together with its
timestamp so the next start fetches again.

The startup path (ensureRecurringAlarms plus the phishing list init) is
registered on onInstalled and onStartup as well as running at the top level
of the worker, and is idempotent.

Firefox MV2 has a persistent background page where timers would have
survived, but both browsers are built from one bundle and both take the
alarm path, so there is a single code path; "alarms" is declared in both
manifests.

src/shared/ens.js keeps its localStorage cache and gains a comment recording
that it is popup-only, so it does not get pulled into the worker later.
2026-08-11 12:27:08 +00:00
11 changed files with 709 additions and 79 deletions

View File

@@ -129,10 +129,11 @@ page, which it does not by default — `script/test-e2e` sets
`PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1` for it. Because that flag is
experimental, the harness does not take it on trust. At launch it waits for the
background worker's **own** startup request — the phishing blocklist fetch that
`src/background/index.js` issues unconditionally — to arrive in the route
handler, and aborts the entire suite if none does within 30 seconds
(`tests/e2e/harness.js`). The check is passive on purpose: a synthetic probe
fetched from inside the worker via `worker.evaluate()` was tried first and
`src/background/index.js` issues on startup, which on the suite's throwaway
profile always happens because no previous fetch timestamp is persisted — to
arrive in the route handler, and aborts the entire suite if none does within 30
seconds (`tests/e2e/harness.js`). The check is passive on purpose: a synthetic
probe fetched from inside the worker via `worker.evaluate()` was tried first and
rejected, because evaluating in an extension service worker that early kills the
worker outright, destroying the thing being measured. Observing traffic the
extension already generates perturbs nothing. Losing the race fails closed — the
@@ -192,9 +193,10 @@ src/
styles/main.css — Tailwind source
views/ — one JS module per screen (home, send, approval, etc.)
shared/ — modules used by both popup and background
alarms.js — recurring background jobs (extension alarms API)
balances.js — ETH + ERC-20 balance fetching via RPC + Blockscout
constants.js — chain IDs, default RPC endpoint, ERC-20 ABI
ens.js — ENS forward/reverse resolution
ens.js — ENS forward/reverse resolution (popup only)
prices.js — ETH/USD and token/USD via CoinDesk API
scamlist.js — known fraud contract addresses
state.js — persisted state (extension storage)
@@ -208,6 +210,39 @@ manifest/
firefox.json — Manifest V2 for Firefox
```
### Background scheduling
Chrome runs `src/background/index.js` as a Manifest V3 service worker, which the
browser terminates after roughly 30 seconds idle and re-evaluates from scratch
on the next event. Two consequences shape every recurring job in the background:
- `setInterval` and `setTimeout` are useless. They are destroyed with the
worker, so a job scheduled that way runs until the first idle period and never
again. Both recurring jobs — the 60-second balance refresh and the 24-hour
phishing blocklist refresh — are scheduled through the extension alarms API
(`src/shared/alarms.js`) instead. The browser holds the schedule and wakes the
worker to deliver it. Alarm periods are clamped to a one-minute minimum, so
the balance refresh is expressed as exactly one minute and nothing is silently
slowed down.
- Module-level variables do not survive either. Anything that must be remembered
across a restart goes in extension storage, including the timestamp of the
last phishing list fetch: without it a revived worker would either re-fetch on
every wake or, with a naive in-memory guard, never notice that an update is
due. `localStorage` does not exist in a service worker at all — the one
remaining user of it, `src/shared/ens.js`, runs only in the popup and is
marked as such.
The startup path (`ensureRecurringAlarms()` plus the phishing list init) runs on
`onInstalled`, on `onStartup`, and at the top level of the worker, so every way
the background context can start re-establishes the schedule. It is idempotent:
an alarm that already exists is left alone, because re-creating one restarts its
period and a busy extension would push the next fire out indefinitely.
Firefox uses Manifest V2 with a persistent background page, where timers would
survive. Both browsers are built from one bundle and both take the alarm path,
so there is a single code path to reason about; `"alarms"` is declared in both
`manifest/chrome.json` and `manifest/firefox.json`.
### UI Design Philosophy
The UI is inspired by _Universal Paperclips_. It's deliberately minimal,
@@ -707,8 +742,11 @@ CoinDesk price API, and Blockscout API), AutistMask also contacts:
blocklist is vendored into the extension at build time. At runtime, the
extension fetches the live list once every 24 hours to detect newly added
domains. Only the delta (domains not already in the vendored list) is kept in
memory, keeping runtime memory usage small. The delta is persisted to
localStorage if it is under 256 KiB.
memory, keeping runtime memory usage small. The delta and the timestamp of the
fetch that produced it are persisted to extension storage if the record is
under 256 KiB; an oversized delta is dropped along with its timestamp, so the
next start fetches again rather than claiming freshness for data it no longer
holds.
- **Etherscan address labels**: When confirming a transaction, the extension
performs a best-effort lookup of the recipient address on Etherscan to check
for phishing/scam labels. This is a direct page fetch with no API key; the
@@ -928,6 +966,10 @@ live list once every 24 hours and keeps only the delta (newly added domains not
in the vendored list) in memory. This architecture keeps runtime memory usage
small while ensuring fresh coverage of new phishing domains.
The 24-hour cadence is an alarm, not a timer, and the fetch timestamp lives in
extension storage rather than in a module variable — see
[Background scheduling](#background-scheduling) for why both are required.
When a dApp on a blocklisted domain requests a wallet connection, transaction
approval, or signature, the approval popup displays a prominent red warning
banner alerting the user. The domain checker matches exact hostnames and all

View File

@@ -44,6 +44,11 @@ undefined identifiers, which is how
# Completed Steps
- 2026-08-11: the balance refresh and the 24-hour phishing list refresh moved
from `setInterval` to the extension alarms API, with the phishing delta and
its fetch timestamp persisted to extension storage, so neither job dies with
the MV3 service worker
([#158](https://git.eeqj.de/sneak/AutistMask/issues/158)).
- 2026-08-11: `docs/README.md` rewritten against the code: no competitor names,
all five network destinations documented, password/Settings/Add Wallet
sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)).

View File

@@ -130,10 +130,12 @@ live list to pick up newly added domains, keeping only the entries not already
in the bundled copy (persisted locally if under 256 KiB). This endpoint is not
user-configurable.
When it is contacted: once when the background script starts, and every 24 hours
after that. It is a plain download of a public file — nothing about you is sent,
but the host sees your IP address. If the fetch fails, the bundled copy is still
used.
When it is contacted: when the background script starts, if the last fetch was
more than 24 hours ago, and every 24 hours after that. The time of the last
fetch is remembered across browser and background restarts, so restarting does
not cause a re-download. It is a plain download of a public file — nothing about
you is sent, but the host sees your IP address. If the fetch fails, the bundled
copy is still used.
**Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia)

View File

@@ -3,7 +3,7 @@
"name": "AutistMask",
"version": "0.1.0",
"description": "Minimal Ethereum wallet for Chrome",
"permissions": ["storage", "activeTab"],
"permissions": ["storage", "activeTab", "alarms"],
"host_permissions": ["<all_urls>"],
"action": {
"default_popup": "src/popup/index.html"

View File

@@ -3,7 +3,7 @@
"name": "AutistMask",
"version": "0.1.0",
"description": "Minimal Ethereum wallet for Firefox",
"permissions": ["storage", "activeTab", "<all_urls>"],
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
"browser_action": {
"default_popup": "src/popup/index.html"
},

View File

@@ -17,8 +17,15 @@ const { verifySignedTx, verifySignature } = require("../shared/approvalVerify");
const {
isPhishingDomain,
updatePhishingList,
startPeriodicRefresh,
initPhishingList,
} = require("../shared/phishingDomains");
const {
BALANCE_REFRESH_ALARM,
PHISHING_REFRESH_ALARM,
BALANCE_REFRESH_PERIOD_MINUTES,
ensureRecurringAlarms,
registerAlarmHandlers,
} = require("../shared/alarms");
const storageApi =
typeof browser !== "undefined"
@@ -591,7 +598,7 @@ async function broadcastAccountsChanged() {
// Background balance refresh: every 60 seconds when the popup isn't open.
// When the popup IS open, its 10-second interval keeps lastBalanceRefresh
// fresh, so this naturally skips.
const BACKGROUND_REFRESH_INTERVAL = 60000;
const BACKGROUND_REFRESH_INTERVAL = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000;
async function backgroundRefresh() {
await loadState();
@@ -609,12 +616,36 @@ async function backgroundRefresh() {
await saveState();
}
setInterval(backgroundRefresh, BACKGROUND_REFRESH_INTERVAL);
// Both recurring jobs run off alarms, not timers. On Chrome MV3 this file is
// a service worker that the browser terminates after about 30 seconds idle,
// so a setInterval would only ever survive until the first idle period and
// module-level state does not outlive it. Alarms are held by the browser and
// wake the worker to deliver them.
registerAlarmHandlers({
[BALANCE_REFRESH_ALARM]: backgroundRefresh,
// Re-reads the persisted fetch timestamp first, so a tick that lands on a
// freshly revived worker neither re-fetches needlessly nor skips an
// overdue update.
[PHISHING_REFRESH_ALARM]: updatePhishingList,
});
// Fetch the phishing domain blocklist delta on startup and refresh every 24h.
// The vendored blocklist is bundled at build time; this fetches only new entries.
updatePhishingList();
startPeriodicRefresh();
// Everything the background context needs re-established on start. This runs
// on a fresh install, on browser startup, and on every revival of a
// terminated worker, so it must be idempotent: ensureRecurringAlarms() only
// creates alarms that are missing, and initPhishingList() fetches only when
// the persisted timestamp says the list is stale.
function startBackgroundJobs() {
ensureRecurringAlarms();
initPhishingList();
}
if (runtime.onInstalled) {
runtime.onInstalled.addListener(startBackgroundJobs);
}
if (runtime.onStartup) {
runtime.onStartup.addListener(startBackgroundJobs);
}
startBackgroundJobs();
// When approval window is closed without a response, treat as rejection
if (windowsApi && windowsApi.onRemoved) {

100
src/shared/alarms.js Normal file
View File

@@ -0,0 +1,100 @@
// Periodic scheduling for the background context.
//
// The Chrome MV3 service worker is terminated after roughly 30 seconds idle,
// which takes every setInterval/setTimeout with it. The extension alarms API
// is the mechanism that survives: the browser holds the schedule and wakes
// the worker to deliver onAlarm. Firefox MV2 runs a persistent background
// page where timers would survive, but alarms behave identically there, so
// both targets share this path and both manifests declare the "alarms"
// permission.
//
// Periods are whole minutes at or above the browser-enforced one-minute
// minimum, so nothing here is silently clamped to a slower cadence.
const BALANCE_REFRESH_ALARM = "autistmask-balance-refresh";
const PHISHING_REFRESH_ALARM = "autistmask-phishing-refresh";
const MIN_ALARM_PERIOD_MINUTES = 1;
const BALANCE_REFRESH_PERIOD_MINUTES = 1;
const PHISHING_REFRESH_PERIOD_MINUTES = 24 * 60;
// Resolved on use rather than captured at module load: the worker is torn
// down and re-evaluated repeatedly, and tests install a stub after requiring
// the module.
function alarmsApi() {
if (typeof browser !== "undefined" && browser.alarms) return browser.alarms;
if (typeof chrome !== "undefined" && chrome.alarms) return chrome.alarms;
return null;
}
/**
* Create an alarm if it does not already exist.
*
* The existence check is load-bearing: creating an alarm resets its schedule,
* and this runs on every worker wake. Creating unconditionally would push the
* next fire time out on every incoming message, so a busy extension would
* never see the alarm fire at all.
*
* @param {string} name
* @param {number} periodInMinutes
* @returns {Promise<boolean>} true if the alarm was created by this call.
*/
async function ensureAlarm(name, periodInMinutes) {
const api = alarmsApi();
if (!api) return false;
const period = Math.max(periodInMinutes, MIN_ALARM_PERIOD_MINUTES);
const existing = await api.get(name);
if (existing) return false;
api.create(name, {
periodInMinutes: period,
delayInMinutes: period,
});
return true;
}
/**
* Ensure both recurring background jobs are scheduled. Safe to call on every
* worker start, on onInstalled and on onStartup.
*
* @returns {Promise<{balance: boolean, phishing: boolean}>} which alarms this
* call had to create.
*/
async function ensureRecurringAlarms() {
const balance = await ensureAlarm(
BALANCE_REFRESH_ALARM,
BALANCE_REFRESH_PERIOD_MINUTES,
);
const phishing = await ensureAlarm(
PHISHING_REFRESH_ALARM,
PHISHING_REFRESH_PERIOD_MINUTES,
);
return { balance, phishing };
}
/**
* Register per-alarm handlers. One listener dispatches by alarm name so the
* worker only ever installs a single onAlarm listener.
*
* @param {Object<string, function>} handlers
* @returns {boolean} true if the listener was installed.
*/
function registerAlarmHandlers(handlers) {
const api = alarmsApi();
if (!api || !api.onAlarm) return false;
api.onAlarm.addListener((alarm) => {
const handler = handlers[alarm && alarm.name];
if (handler) handler();
});
return true;
}
module.exports = {
BALANCE_REFRESH_ALARM,
PHISHING_REFRESH_ALARM,
MIN_ALARM_PERIOD_MINUTES,
BALANCE_REFRESH_PERIOD_MINUTES,
PHISHING_REFRESH_PERIOD_MINUTES,
ensureAlarm,
ensureRecurringAlarms,
registerAlarmHandlers,
};

View File

@@ -1,6 +1,11 @@
// Cached ENS reverse resolution.
// Resolves addresses to ENS names via ethers provider.lookupAddress(),
// caching results in localStorage with a 12-hour TTL.
//
// POPUP ONLY. localStorage does not exist in the Chrome MV3 service worker,
// so this module must not be pulled into src/background/. Anything the
// background context needs to cache goes in extension storage instead (see
// shared/phishingDomains.js).
const { getProvider } = require("./balances");
const { log } = require("./log");

View File

@@ -8,8 +8,12 @@
// The domain-checker checks the in-memory delta first (fresh/recent scam
// sites), then falls back to the vendored list.
//
// If the delta is under 256 KiB it is persisted to localStorage so it
// survives extension/service-worker restarts.
// If the delta and its fetch timestamp fit in 256 KiB they are persisted to
// extension storage, so they survive termination of the MV3 service worker.
// Extension storage, not localStorage: localStorage does not exist in a
// service worker, so the previous persistence never ran on Chrome at all.
// The stored timestamp is what keeps a restarted worker from re-fetching on
// every wake while still noticing an overdue update.
const vendoredConfig = require("./phishingBlocklist.json");
@@ -17,7 +21,6 @@ const BLOCKLIST_URL =
"https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json";
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
const DELTA_STORAGE_KEY = "phishing-delta";
const MAX_DELTA_BYTES = 256 * 1024; // 256 KiB
@@ -30,44 +33,79 @@ const vendoredBlacklist = new Set(
let deltaBlacklist = new Set();
let lastFetchTime = 0;
let fetchPromise = null;
let refreshTimer = null;
let loadPromise = null;
// Resolved on use rather than captured at module load, so a test can install
// a stub after requiring the module and so the popup — which has no reason to
// touch the delta — does not fail to load where the API is absent.
function storageApi() {
if (typeof browser !== "undefined" && browser.storage) {
return browser.storage.local;
}
if (typeof chrome !== "undefined" && chrome.storage) {
return chrome.storage.local;
}
return null;
}
/**
* Load delta entries from localStorage on startup.
* Called once during module initialization in the background script.
* Load the persisted delta and its fetch timestamp from extension storage.
* Runs once per worker lifetime; every entry point funnels through
* ensureDeltaLoaded() so a wake from termination restores state exactly once.
*
* @returns {Promise<void>}
*/
function loadDeltaFromStorage() {
async function loadDeltaFromStorage() {
const storage = storageApi();
if (!storage) return;
try {
const raw = localStorage.getItem(DELTA_STORAGE_KEY);
if (!raw) return;
const data = JSON.parse(raw);
if (data.blacklist && Array.isArray(data.blacklist)) {
const result = await storage.get(DELTA_STORAGE_KEY);
const data = result && result[DELTA_STORAGE_KEY];
if (!data) return;
if (Array.isArray(data.blacklist)) {
deltaBlacklist = new Set(
data.blacklist.map((d) => d.toLowerCase()),
);
}
if (typeof data.lastFetchTime === "number") {
lastFetchTime = data.lastFetchTime;
}
} catch {
// localStorage unavailable or corrupt — start empty
// Storage unavailable or corrupt — start empty and re-fetch.
}
}
function ensureDeltaLoaded() {
if (!loadPromise) loadPromise = loadDeltaFromStorage();
return loadPromise;
}
/**
* Persist delta to localStorage if it fits within MAX_DELTA_BYTES.
* Persist the delta and its timestamp if they fit within MAX_DELTA_BYTES.
*
* The cap covers the whole record: when the delta is too large to keep, the
* timestamp goes with it, so the next worker start re-fetches rather than
* trusting a freshness claim for a delta it no longer holds.
*
* @returns {Promise<void>}
*/
function saveDeltaToStorage() {
async function saveDeltaToStorage() {
const storage = storageApi();
if (!storage) return;
try {
const data = {
blacklist: Array.from(deltaBlacklist),
lastFetchTime,
};
const json = JSON.stringify(data);
if (json.length < MAX_DELTA_BYTES) {
localStorage.setItem(DELTA_STORAGE_KEY, json);
await storage.set({ [DELTA_STORAGE_KEY]: data });
} else {
// Too large — remove stale key if present
localStorage.removeItem(DELTA_STORAGE_KEY);
// Too large — remove stale record if present
await storage.remove(DELTA_STORAGE_KEY);
}
} catch {
// localStorage unavailable — skip silently
// Storage unavailable — skip silently
}
}
@@ -76,6 +114,7 @@ function saveDeltaToStorage() {
* Used for both live fetches and testing.
*
* @param {{ blacklist?: string[] }} config
* @returns {Promise<void>} resolves once the delta has been persisted.
*/
function loadConfig(config) {
const liveBlacklist = (config.blacklist || []).map((d) => d.toLowerCase());
@@ -86,7 +125,7 @@ function loadConfig(config) {
);
lastFetchTime = Date.now();
saveDeltaToStorage();
return saveDeltaToStorage();
}
/**
@@ -111,6 +150,11 @@ function hostnameVariants(hostname) {
* Check if a hostname is on the phishing blocklist.
* Checks delta first (fresh/recent scam sites), then vendored list.
*
* Synchronous by design — callers answer an approval prompt with it. On a
* worker that has just woken, the persisted delta may still be loading; the
* vendored list, which is bundled and always present, carries the check until
* it lands.
*
* @param {string} hostname - The hostname to check.
* @returns {boolean}
*/
@@ -127,11 +171,17 @@ function isPhishingDomain(hostname) {
/**
* Fetch the latest blocklist and compute delta against vendored data.
* De-duplicates concurrent fetches. Results are cached for CACHE_TTL_MS.
* De-duplicates concurrent fetches. Results are cached for CACHE_TTL_MS,
* counted from the persisted timestamp so the cache outlives the worker.
*
* @returns {Promise<void>}
*/
async function updatePhishingList() {
// A worker that has just been revived knows nothing until the persisted
// record is back in memory; without this the freshness check below would
// always see 0 and re-fetch on every wake.
await ensureDeltaLoaded();
// Skip if recently fetched
if (Date.now() - lastFetchTime < CACHE_TTL_MS && lastFetchTime > 0) {
return;
@@ -145,7 +195,7 @@ async function updatePhishingList() {
const resp = await fetch(BLOCKLIST_URL);
if (!resp.ok) throw new Error("HTTP " + resp.status);
const config = await resp.json();
loadConfig(config);
await loadConfig(config);
} catch {
// Silently fail — vendored list still provides coverage.
// We'll retry next time.
@@ -158,12 +208,18 @@ async function updatePhishingList() {
}
/**
* Start periodic refresh of the phishing list.
* Should be called once from the background script on startup.
* Restore persisted state and fetch if the list is overdue.
*
* Called from the background script every time it starts — a fresh install,
* a browser start, and every revival of a terminated service worker all land
* here. The recurring 24-hour schedule itself is an alarm (see
* shared/alarms.js), not a timer, because timers die with the worker.
*
* @returns {Promise<void>}
*/
function startPeriodicRefresh() {
if (refreshTimer) return;
refreshTimer = setInterval(updatePhishingList, REFRESH_INTERVAL_MS);
async function initPhishingList() {
await ensureDeltaLoaded();
return updatePhishingList();
}
/**
@@ -191,20 +247,18 @@ function _reset() {
deltaBlacklist = new Set();
lastFetchTime = 0;
fetchPromise = null;
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
loadPromise = null;
}
// Load persisted delta on module initialization
loadDeltaFromStorage();
module.exports = {
isPhishingDomain,
updatePhishingList,
startPeriodicRefresh,
initPhishingList,
loadDeltaFromStorage,
loadConfig,
CACHE_TTL_MS,
DELTA_STORAGE_KEY,
MAX_DELTA_BYTES,
getBlocklistSize,
getDeltaSize,
hostnameVariants,

266
tests/alarms.test.js Normal file
View File

@@ -0,0 +1,266 @@
// 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.
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("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);
});
});
describe("background worker scheduling", () => {
let alarmsStub;
let timers;
function loadBackground() {
const storageStore = {};
alarmsStub = makeAlarmsStub();
const listeners = { onInstalled: [], onStartup: [] };
global.chrome = {
alarms: alarmsStub,
storage: {
local: {
get: async (key) =>
Object.prototype.hasOwnProperty.call(storageStore, key)
? { [key]: storageStore[key] }
: {},
set: async (items) => 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 listeners;
}
beforeEach(() => {
timers = {
setInterval: jest
.spyOn(global, "setInterval")
.mockImplementation(() => 0),
};
});
afterEach(() => {
timers.setInterval.mockRestore();
delete global.chrome;
delete global.fetch;
jest.resetModules();
});
test("startup schedules the recurring jobs as alarms, not timers", async () => {
loadBackground();
// Let the startup path's promises settle.
await new Promise((resolve) => setImmediate(resolve));
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(timers.setInterval).not.toHaveBeenCalled();
});
test("an onAlarm listener is installed on startup", async () => {
loadBackground();
await new Promise((resolve) => setImmediate(resolve));
expect(alarmsStub.listenerCount()).toBe(1);
});
test("onInstalled and onStartup both re-establish the schedule", async () => {
const listeners = loadBackground();
await new Promise((resolve) => setImmediate(resolve));
expect(listeners.onInstalled).toHaveLength(1);
expect(listeners.onStartup).toHaveLength(1);
// A browser start after the alarms were dropped must put them back.
alarmsStub.alarms.clear();
alarmsStub.created.length = 0;
listeners.onStartup[0]();
await new Promise((resolve) => setImmediate(resolve));
expect(alarmsStub.created).toHaveLength(2);
});
});

View File

@@ -1,17 +1,24 @@
// Provide a localStorage mock for Node.js test environment.
// Must be set before requiring the module since it calls loadDeltaFromStorage()
// at module load time.
const localStorageStore = {};
global.localStorage = {
getItem: (key) =>
Object.prototype.hasOwnProperty.call(localStorageStore, key)
? localStorageStore[key]
: null,
setItem: (key, value) => {
localStorageStore[key] = String(value);
},
removeItem: (key) => {
delete localStorageStore[key];
// Extension storage stub for the Node test environment. The module resolves
// the storage API on use, so this only has to exist before the first call.
// Values round-trip through JSON the way structured cloning would, so a test
// cannot pass by holding a live reference to the module's own array.
const storageStore = {};
global.chrome = {
storage: {
local: {
get: async (key) =>
Object.prototype.hasOwnProperty.call(storageStore, key)
? { [key]: JSON.parse(JSON.stringify(storageStore[key])) }
: {},
set: async (items) => {
for (const [key, value] of Object.entries(items)) {
storageStore[key] = JSON.parse(JSON.stringify(value));
}
},
remove: async (key) => {
delete storageStore[key];
},
},
},
};
@@ -21,19 +28,23 @@ const {
getBlocklistSize,
getDeltaSize,
hostnameVariants,
DELTA_STORAGE_KEY,
_reset,
_getVendoredBlacklistSize,
_getDeltaBlacklist,
} = require("../src/shared/phishingDomains");
function clearStorage() {
for (const key of Object.keys(storageStore)) {
delete storageStore[key];
}
}
// Reset delta state before each test to avoid cross-test contamination.
// Note: vendored sets are immutable and always present.
beforeEach(() => {
_reset();
// Clear localStorage mock between tests
for (const key of Object.keys(localStorageStore)) {
delete localStorageStore[key];
}
clearStorage();
});
describe("phishingDomains", () => {
@@ -169,15 +180,34 @@ describe("phishingDomains", () => {
});
});
describe("localStorage persistence", () => {
test("saveDeltaToStorage persists delta under 256KiB", () => {
loadConfig({
describe("extension storage persistence", () => {
test("delta is persisted to extension storage, not localStorage", async () => {
await loadConfig({
blacklist: ["persisted-scam-xyz.com"],
});
const stored = localStorage.getItem("phishing-delta");
expect(stored).not.toBeNull();
const data = JSON.parse(stored);
expect(data.blacklist).toContain("persisted-scam-xyz.com");
const stored = storageStore[DELTA_STORAGE_KEY];
expect(stored).toBeDefined();
expect(stored.blacklist).toContain("persisted-scam-xyz.com");
});
test("the fetch timestamp is persisted alongside the delta", async () => {
const before = Date.now();
await loadConfig({ blacklist: ["timestamped-scam-xyz.com"] });
const stored = storageStore[DELTA_STORAGE_KEY];
expect(typeof stored.lastFetchTime).toBe("number");
expect(stored.lastFetchTime).toBeGreaterThanOrEqual(before);
});
test("an oversized delta is dropped entirely, timestamp included", async () => {
// A record above the 256 KiB cap is not worth keeping; the
// timestamp goes with it so the next start re-fetches rather than
// claiming freshness for a delta that was never stored.
const huge = [];
for (let i = 0; i < 20000; i++) {
huge.push(`oversize-scam-${i}-xyzxyzxyzxyzxyz.com`);
}
await loadConfig({ blacklist: huge });
expect(storageStore[DELTA_STORAGE_KEY]).toBeUndefined();
});
test("delta is cleared on _reset", () => {
@@ -203,3 +233,98 @@ describe("phishingDomains", () => {
});
});
});
// The MV3 service worker is torn down when idle and re-evaluated on the next
// event, which wipes every module-level variable. Re-requiring the module
// with the module registry reset is exactly that: fresh in-memory state, same
// extension storage underneath.
describe("phishing list across a service worker restart", () => {
function restartWorker() {
jest.resetModules();
return require("../src/shared/phishingDomains");
}
beforeEach(() => {
clearStorage();
jest.resetModules();
});
afterEach(() => {
delete global.fetch;
});
test("a revived worker restores the persisted delta without re-fetching", async () => {
const first = require("../src/shared/phishingDomains");
await first.loadConfig({ blacklist: ["restart-scam-xyz.com"] });
const revived = restartWorker();
// Nothing in memory yet — this is a brand new module instance.
expect(revived.getDeltaSize()).toBe(0);
global.fetch = jest.fn();
await revived.initPhishingList();
expect(global.fetch).not.toHaveBeenCalled();
expect(revived.getDeltaSize()).toBe(1);
expect(revived.isPhishingDomain("restart-scam-xyz.com")).toBe(true);
});
test("repeated wakes inside the cache window never re-fetch", async () => {
const first = require("../src/shared/phishingDomains");
await first.loadConfig({ blacklist: ["no-storm-scam-xyz.com"] });
global.fetch = jest.fn();
for (let i = 0; i < 5; i++) {
const revived = restartWorker();
await revived.initPhishingList();
}
expect(global.fetch).not.toHaveBeenCalled();
});
test("a persisted timestamp older than the TTL causes a fetch on startup", async () => {
const first = require("../src/shared/phishingDomains");
await first.loadConfig({ blacklist: ["stale-scam-xyz.com"] });
// Age the persisted record past the 24-hour TTL.
storageStore[first.DELTA_STORAGE_KEY].lastFetchTime =
Date.now() - first.CACHE_TTL_MS - 1000;
const revived = restartWorker();
global.fetch = jest.fn(async () => ({
ok: true,
json: async () => ({ blacklist: ["refreshed-scam-xyz.com"] }),
}));
await revived.initPhishingList();
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(revived.isPhishingDomain("refreshed-scam-xyz.com")).toBe(true);
expect(revived.isPhishingDomain("stale-scam-xyz.com")).toBe(false);
});
test("a first start with nothing persisted fetches immediately", async () => {
const fresh = restartWorker();
global.fetch = jest.fn(async () => ({
ok: true,
json: async () => ({ blacklist: ["first-run-scam-xyz.com"] }),
}));
await fresh.initPhishingList();
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(fresh.isPhishingDomain("first-run-scam-xyz.com")).toBe(true);
});
test("updatePhishingList honours the persisted timestamp on its own", async () => {
// The alarm handler calls updatePhishingList() directly, so it must
// load persisted state itself rather than relying on the startup path
// having finished first.
const first = require("../src/shared/phishingDomains");
await first.loadConfig({ blacklist: ["alarm-tick-scam-xyz.com"] });
const revived = restartWorker();
global.fetch = jest.fn();
await revived.updatePhishingList();
expect(global.fetch).not.toHaveBeenCalled();
expect(revived.isPhishingDomain("alarm-tick-scam-xyz.com")).toBe(true);
});
});