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
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 or when the existing one carries a different period, because creating one restarts its period and the startup path runs on every wake — while an alarm left at an older release's period would otherwise never be reconciled. Each job's freshness guard is decoupled from its alarm period, or the period would not be the cadence. A guard is measured from when the last run finished, which is one run-duration after the alarm that started it, so a guard timed to the period vetoes the very next tick and the real rate halves. The two are handled differently because the guards differ in purpose: the phishing cache TTL exists to keep the worker off the network on the wakes between refreshes, so the scheduled tick bypasses it and fetches unconditionally; the balance guard exists to skip work an open popup has already done, so it must keep applying on the tick and is instead shortened to half the alarm period — above the popup's 10-second refresh, below the 60-second period. The phishing delta and the timestamps 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. A timestamp read back from storage is discarded if it lies in the future: clock skew or a restored profile backup would otherwise suppress updates until that time arrived, permanently, now that the value outlives the worker. Two timestamps are kept, not one. The 256 KiB cap still drops an oversized delta together with its freshness claim, but the record of having contacted the network at all is written regardless — as it is after a failed fetch — and floors unscheduled retries at one hour. Without it, a list that is persistently oversized or a fetch that persistently fails means a full blocklist download on every worker wake, indefinitely. 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. The concurrent callers on a fresh install share one in-flight run rather than racing to create the same alarm, and a failure is logged instead of becoming an unhandled rejection. 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.
This commit is contained in:
97
README.md
97
README.md
@@ -145,10 +145,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
|
||||
@@ -208,9 +209,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)
|
||||
@@ -224,6 +226,74 @@ 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.
|
||||
|
||||
Both jobs also carry a freshness guard, and a guard must never be timed to the
|
||||
alarm period it gates. Each guard is measured from the moment the last run
|
||||
finished, which is one run-duration after the alarm that started it, so a guard
|
||||
of exactly one period vetoes the very next tick and the real cadence becomes two
|
||||
periods. The two jobs solve this differently, because their guards exist for
|
||||
different reasons:
|
||||
|
||||
- The phishing refresh has a 24-hour cache TTL whose job is to keep the worker
|
||||
off the network on the wakes between scheduled refreshes — Chrome revives the
|
||||
worker every ~30 seconds while the browser is busy, and every revival runs the
|
||||
startup path. The scheduled alarm tick is not one of those wakes, so it
|
||||
bypasses the TTL and fetches unconditionally. Shortening the TTL instead would
|
||||
not work: the startup path re-checks it on every wake, so a shorter TTL simply
|
||||
becomes the real refresh rate.
|
||||
- The balance refresh guard exists to skip work an open popup has already done —
|
||||
the popup refreshes every 10 seconds and stamps the same field. That has to
|
||||
keep applying on the scheduled tick, so the guard is shortened to half the
|
||||
alarm period instead of bypassed: comfortably above the popup's 10 seconds, so
|
||||
an open popup still suppresses the background job, and comfortably below the
|
||||
60-second period, so the schedule always wins.
|
||||
|
||||
Two timestamps are persisted for the phishing list, not one. `lastFetchTime`
|
||||
records a fetch that produced a usable delta and drives the TTL.
|
||||
`lastAttemptTime` records that the network was contacted at all, and is written
|
||||
even when the result is unusable — a failed request, or a delta over the 256 KiB
|
||||
cap. Without it those cases leave no freshness mark and the worker re-downloads
|
||||
the full blocklist on every wake, indefinitely; with it, unscheduled retries are
|
||||
floored at one hour. Both are discarded on load if they are in the future, since
|
||||
a stamp from a skewed clock or a restored backup would otherwise suppress
|
||||
updates until that time arrives, permanently and with no way out.
|
||||
|
||||
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. On a fresh install
|
||||
more than one of those fires, so they share a single in-flight run rather than
|
||||
racing. It is idempotent: an alarm that already exists with the period the code
|
||||
asks for is left alone, because re-creating one restarts its schedule and a busy
|
||||
extension would push the next fire out indefinitely. An alarm carrying a
|
||||
different period — one created by an earlier version — is re-created once, or a
|
||||
period changed in a new release would never reach an existing install.
|
||||
|
||||
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,
|
||||
@@ -902,9 +972,14 @@ CoinDesk price API, and Blockscout API), AutistMask also contacts:
|
||||
- **Phishing domain blocklist**: A community-maintained phishing domain
|
||||
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.
|
||||
domains, plus once on a start where the list is more than 24 hours old. Only
|
||||
the delta (domains not already in the vendored list) is kept in 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 a later start
|
||||
fetches again rather than claiming freshness for data it no longer holds. A
|
||||
fetch that fails, or one whose delta was too large to store, is not retried
|
||||
more than once an hour outside the 24-hour schedule.
|
||||
- **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
|
||||
@@ -1127,6 +1202,12 @@ 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; the alarm tick fetches
|
||||
unconditionally rather than re-checking the 24-hour cache TTL that gates the
|
||||
startup path; and the fetch timestamps live in extension storage rather than in
|
||||
module variables — see [Background scheduling](#background-scheduling) for why
|
||||
all three 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
|
||||
|
||||
7
TODO.md
7
TODO.md
@@ -49,6 +49,13 @@ undefined identifiers, which is how
|
||||
`src/shared/transactions.js` goes through one case-normalising helper so a
|
||||
checksummed genuine contract is no longer read as a spoof
|
||||
([#179](https://git.eeqj.de/sneak/AutistMask/issues/179)).
|
||||
- 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 timestamps persisted to extension storage, so neither job dies with
|
||||
the MV3 service worker. Each job's freshness guard was decoupled from its
|
||||
alarm period at the same time — timed to the period, a guard vetoes its own
|
||||
scheduled tick and halves the real refresh rate
|
||||
([#158](https://git.eeqj.de/sneak/AutistMask/issues/158)).
|
||||
- 2026-08-11: Policy compliance sweep — conditional verbose test rerun, local
|
||||
Tailwind binary instead of `npx`, `--frozen-lockfile` on `make install`, and
|
||||
the Makefile-only targets documented in the README
|
||||
|
||||
@@ -130,10 +130,14 @@ 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. If a fetch fails, or the list is too large to keep, the
|
||||
extension waits an hour before trying again outside that 24-hour schedule rather
|
||||
than retrying on every restart. 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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -12,13 +12,20 @@ const {
|
||||
currentNetwork,
|
||||
} = require("../shared/state");
|
||||
const { refreshBalances, getProvider } = require("../shared/balances");
|
||||
const { debugFetch } = require("../shared/log");
|
||||
const { debugFetch, log } = require("../shared/log");
|
||||
const { verifySignedTx, verifySignature } = require("../shared/approvalVerify");
|
||||
const {
|
||||
isPhishingDomain,
|
||||
updatePhishingList,
|
||||
startPeriodicRefresh,
|
||||
refreshPhishingListOnSchedule,
|
||||
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,12 +598,22 @@ 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;
|
||||
//
|
||||
// The alarm period alone sets the cadence; this guard only suppresses a
|
||||
// refresh something else has just done, so it must stay strictly shorter than
|
||||
// the period. Timed to the period it would veto every tick it gates —
|
||||
// lastBalanceRefresh is stamped after the refresh runs, so a tick one period
|
||||
// after the last one always lands inside a guard of equal length and the real
|
||||
// cadence becomes two periods. Half the period keeps it comfortably above the
|
||||
// popup's 10-second refresh, so an open popup still suppresses the background
|
||||
// job, and comfortably below the alarm period, so the schedule always wins.
|
||||
const BALANCE_REFRESH_PERIOD_MS = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000;
|
||||
const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2);
|
||||
|
||||
async function backgroundRefresh() {
|
||||
await loadState();
|
||||
const now = Date.now();
|
||||
if (now - (state.lastBalanceRefresh || 0) < BACKGROUND_REFRESH_INTERVAL)
|
||||
if (now - (state.lastBalanceRefresh || 0) < RECENT_BALANCE_REFRESH_MS)
|
||||
return;
|
||||
if (state.wallets.length === 0) return;
|
||||
await refreshBalances(
|
||||
@@ -609,12 +626,58 @@ 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,
|
||||
// The scheduled refresh, which restores persisted state on a freshly
|
||||
// revived worker and then fetches unconditionally. The freshness guards
|
||||
// belong to the startup path; applying them here would make the tick skip
|
||||
// itself.
|
||||
[PHISHING_REFRESH_ALARM]: refreshPhishingListOnSchedule,
|
||||
});
|
||||
|
||||
// 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 or carrying a stale period, and
|
||||
// initPhishingList() fetches only when the persisted timestamps say the list
|
||||
// is stale.
|
||||
//
|
||||
// On a fresh install the top-level call and the onInstalled listener both run,
|
||||
// close enough together that both could see an alarm missing and create it.
|
||||
// Sharing one in-flight run makes the "create only when missing" check
|
||||
// race-free; the memo is dropped once it settles so a later onStartup runs
|
||||
// again.
|
||||
let backgroundJobsRun = null;
|
||||
|
||||
function startBackgroundJobs() {
|
||||
if (backgroundJobsRun) return backgroundJobsRun;
|
||||
backgroundJobsRun = Promise.all([
|
||||
ensureRecurringAlarms(),
|
||||
initPhishingList(),
|
||||
])
|
||||
.catch((err) => {
|
||||
// An alarm that failed to schedule means a recurring job silently
|
||||
// never runs again; it must not be an unhandled rejection.
|
||||
log.errorf("background job startup failed:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
backgroundJobsRun = null;
|
||||
});
|
||||
return backgroundJobsRun;
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
114
src/shared/alarms.js
Normal file
114
src/shared/alarms.js
Normal file
@@ -0,0 +1,114 @@
|
||||
// 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.
|
||||
//
|
||||
// Trap for anyone changing a period: each job also carries a freshness guard
|
||||
// that can veto its own scheduled tick. A guard timed to the alarm period
|
||||
// halves the real cadence, because the guard is measured from when the last
|
||||
// run finished and the alarm fires one run-duration earlier than that. Every
|
||||
// guard must therefore either be strictly shorter than the period it gates or
|
||||
// be bypassed on the scheduled tick — see backgroundRefresh() in
|
||||
// src/background/index.js and updatePhishingList() in shared/phishingDomains.js.
|
||||
|
||||
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 unless one with the requested period already exists.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* The period comparison is equally load-bearing in the other direction: an
|
||||
* alarm created by an older version keeps its old period forever unless a
|
||||
* changed constant re-creates it, so a period edit would never reach an
|
||||
* existing install. Re-creating on a period change happens once and then
|
||||
* settles into the existence check above.
|
||||
*
|
||||
* @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 && existing.periodInMinutes === period) 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,
|
||||
};
|
||||
@@ -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");
|
||||
|
||||
@@ -8,8 +8,14 @@
|
||||
// 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 timestamps are what keep a restarted worker from re-fetching on
|
||||
// every wake while still noticing an overdue update. Those guards apply to the
|
||||
// startup path only; the 24-hour alarm tick bypasses them, or it would veto
|
||||
// its own refresh — see updatePhishingList().
|
||||
|
||||
const vendoredConfig = require("./phishingBlocklist.json");
|
||||
|
||||
@@ -17,7 +23,14 @@ 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
|
||||
|
||||
// Floor on how often an unscheduled path may hit the network. The worker is
|
||||
// revived every ~30 seconds while the browser is busy, and every revival runs
|
||||
// the startup path; without a persisted record of the last attempt, any state
|
||||
// that leaves lastFetchTime unset — a fetch that failed, or a delta too large
|
||||
// to store — would download the full list on every single wake.
|
||||
const MIN_FETCH_ATTEMPT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
const DELTA_STORAGE_KEY = "phishing-delta";
|
||||
const MAX_DELTA_BYTES = 256 * 1024; // 256 KiB
|
||||
|
||||
@@ -29,45 +42,104 @@ const vendoredBlacklist = new Set(
|
||||
// Delta set — only entries from live list that are NOT in vendored.
|
||||
let deltaBlacklist = new Set();
|
||||
let lastFetchTime = 0;
|
||||
let lastAttemptTime = 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.
|
||||
* Sanitise a timestamp read back from storage.
|
||||
*
|
||||
* A value in the future is permanent poison: every guard here measures elapsed
|
||||
* time as `Date.now() - stamp` and tests only the lower bound, so a stamp a
|
||||
* year ahead suppresses updates for a year with no path that ever clears it.
|
||||
* Clock skew and a restored profile backup both produce one. Since these
|
||||
* timestamps only ever gate work, discarding an impossible one is safe: it
|
||||
* costs at most a single extra fetch and restores a sane value immediately.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @returns {number} the timestamp, or 0 if it is unusable.
|
||||
*/
|
||||
function loadDeltaFromStorage() {
|
||||
function sanitizeTimestamp(value) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
|
||||
if (value <= 0 || value > Date.now()) return 0;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the persisted delta and its timestamps 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>}
|
||||
*/
|
||||
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()),
|
||||
);
|
||||
}
|
||||
lastFetchTime = sanitizeTimestamp(data.lastFetchTime);
|
||||
lastAttemptTime = sanitizeTimestamp(data.lastAttemptTime);
|
||||
} 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 timestamps if they fit within MAX_DELTA_BYTES.
|
||||
*
|
||||
* The 256 KiB cap covers the delta and its freshness claim: when the delta is
|
||||
* too large to keep, lastFetchTime goes with it, so the next start re-fetches
|
||||
* rather than trusting a freshness claim for a delta it no longer holds.
|
||||
* lastAttemptTime is written either way — it records that the network was
|
||||
* contacted, which stays true whatever became of the response, and it is what
|
||||
* stops a permanently oversized list from downloading on every worker wake.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function saveDeltaToStorage() {
|
||||
async function saveDeltaToStorage() {
|
||||
const storage = storageApi();
|
||||
if (!storage) return;
|
||||
try {
|
||||
const data = {
|
||||
blacklist: Array.from(deltaBlacklist),
|
||||
lastFetchTime,
|
||||
lastAttemptTime,
|
||||
};
|
||||
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 if (lastAttemptTime > 0) {
|
||||
await storage.set({ [DELTA_STORAGE_KEY]: { lastAttemptTime } });
|
||||
} else {
|
||||
// Too large — remove stale key if present
|
||||
localStorage.removeItem(DELTA_STORAGE_KEY);
|
||||
await storage.remove(DELTA_STORAGE_KEY);
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable — skip silently
|
||||
// Storage unavailable — skip silently
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +148,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 +159,7 @@ function loadConfig(config) {
|
||||
);
|
||||
|
||||
lastFetchTime = Date.now();
|
||||
saveDeltaToStorage();
|
||||
return saveDeltaToStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +184,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,28 +205,59 @@ 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.
|
||||
*
|
||||
* `force` is what makes the 24-hour alarm actually refresh every 24 hours.
|
||||
* The alarm fires one period after the previous alarm, but lastFetchTime is
|
||||
* stamped when that fetch *completed*, so an unforced tick lands one fetch
|
||||
* latency inside its own TTL, skips, and turns the real cadence into 48 hours.
|
||||
* Shortening the TTL instead would not fix it: the worker wakes every ~30
|
||||
* seconds and the startup path re-checks the TTL each time, so a shortened TTL
|
||||
* simply becomes the real cadence. The TTL is there to stop redundant fetches
|
||||
* on wake, and the scheduled tick is not redundant, so it bypasses it.
|
||||
*
|
||||
* @param {{force?: boolean}} [opts] force: fetch unless one is already in
|
||||
* flight, ignoring both the freshness and the retry guard. For the scheduled
|
||||
* alarm tick only.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function updatePhishingList() {
|
||||
// Skip if recently fetched
|
||||
if (Date.now() - lastFetchTime < CACHE_TTL_MS && lastFetchTime > 0) {
|
||||
return;
|
||||
async function updatePhishingList({ force = false } = {}) {
|
||||
// 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();
|
||||
|
||||
if (!force) {
|
||||
const now = Date.now();
|
||||
// Skip if recently fetched.
|
||||
if (lastFetchTime > 0 && now - lastFetchTime < CACHE_TTL_MS) return;
|
||||
// Skip if the network was contacted recently and the result was not
|
||||
// usable — a failed fetch or an oversized delta leaves lastFetchTime
|
||||
// unset, and without this every wake would retry.
|
||||
if (
|
||||
lastAttemptTime > 0 &&
|
||||
now - lastAttemptTime < MIN_FETCH_ATTEMPT_INTERVAL_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// De-duplicate concurrent calls
|
||||
if (fetchPromise) return fetchPromise;
|
||||
|
||||
fetchPromise = (async () => {
|
||||
lastAttemptTime = Date.now();
|
||||
try {
|
||||
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.
|
||||
// Silently fail — vendored list still provides coverage. Persist
|
||||
// the attempt so a persistently failing fetch is retried on the
|
||||
// schedule rather than on every wake.
|
||||
await saveDeltaToStorage();
|
||||
} finally {
|
||||
fetchPromise = null;
|
||||
}
|
||||
@@ -158,12 +267,29 @@ 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* The 24-hour alarm tick. Separate from initPhishingList() because this is the
|
||||
* scheduled refresh and must not be vetoed by the guards that exist to keep
|
||||
* the unscheduled startup path off the network.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function refreshPhishingListOnSchedule() {
|
||||
return updatePhishingList({ force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,21 +316,22 @@ function getDeltaSize() {
|
||||
function _reset() {
|
||||
deltaBlacklist = new Set();
|
||||
lastFetchTime = 0;
|
||||
lastAttemptTime = 0;
|
||||
fetchPromise = null;
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
loadPromise = null;
|
||||
}
|
||||
|
||||
// Load persisted delta on module initialization
|
||||
loadDeltaFromStorage();
|
||||
|
||||
module.exports = {
|
||||
isPhishingDomain,
|
||||
updatePhishingList,
|
||||
startPeriodicRefresh,
|
||||
refreshPhishingListOnSchedule,
|
||||
initPhishingList,
|
||||
loadDeltaFromStorage,
|
||||
loadConfig,
|
||||
CACHE_TTL_MS,
|
||||
MIN_FETCH_ATTEMPT_INTERVAL_MS,
|
||||
DELTA_STORAGE_KEY,
|
||||
MAX_DELTA_BYTES,
|
||||
getBlocklistSize,
|
||||
getDeltaSize,
|
||||
hostnameVariants,
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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,32 @@ 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];
|
||||
}
|
||||
}
|
||||
|
||||
// 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 registry reset is exactly that: fresh in-memory state, same extension
|
||||
// storage underneath.
|
||||
function restartWorker() {
|
||||
jest.resetModules();
|
||||
return require("../src/shared/phishingDomains");
|
||||
}
|
||||
|
||||
// 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 +189,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 +242,332 @@ describe("phishingDomains", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("phishing list across a service worker restart", () => {
|
||||
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 startup path calls updatePhishingList() directly, so it must
|
||||
// load persisted state itself rather than relying on anything else
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
// The alarm period alone must set the cadence. lastFetchTime is stamped when
|
||||
// the fetch completes, so it lands one fetch latency after the alarm that
|
||||
// caused it; a freshness guard timed to the alarm period therefore vetoes
|
||||
// every scheduled tick and halves the real refresh rate. These tests measure
|
||||
// the interval between fetches that actually happened.
|
||||
describe("phishing refresh steady-state cadence", () => {
|
||||
const { PHISHING_REFRESH_PERIOD_MINUTES } = require("../src/shared/alarms");
|
||||
const PERIOD_MS = PHISHING_REFRESH_PERIOD_MINUTES * 60 * 1000;
|
||||
|
||||
let clockSpy;
|
||||
let now;
|
||||
|
||||
beforeEach(() => {
|
||||
clearStorage();
|
||||
jest.resetModules();
|
||||
now = Date.UTC(2026, 0, 1, 0, 0, 0);
|
||||
clockSpy = jest.spyOn(Date, "now").mockImplementation(() => now);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clockSpy.mockRestore();
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
function fetchStub(latencyMs, seen) {
|
||||
return jest.fn(async () => {
|
||||
seen.push(now);
|
||||
// A network fetch takes time, and lastFetchTime is stamped after
|
||||
// it, not when the alarm fired.
|
||||
now += latencyMs;
|
||||
return { ok: true, json: async () => ({ blacklist: [] }) };
|
||||
});
|
||||
}
|
||||
|
||||
test("ten alarm ticks produce ten fetches, one per period", async () => {
|
||||
const fetchedAt = [];
|
||||
global.fetch = fetchStub(5000, fetchedAt);
|
||||
|
||||
const startup = require("../src/shared/phishingDomains");
|
||||
const T0 = now;
|
||||
await startup.initPhishingList();
|
||||
expect(fetchedAt).toEqual([T0]);
|
||||
|
||||
const TICKS = 10;
|
||||
let tickAt = T0 + PERIOD_MS;
|
||||
for (let i = 0; i < TICKS; i++) {
|
||||
now = tickAt;
|
||||
tickAt += PERIOD_MS;
|
||||
// The browser wakes a terminated worker to deliver the alarm, so
|
||||
// every tick starts from cold memory and the persisted record.
|
||||
const revived = restartWorker();
|
||||
await revived.refreshPhishingListOnSchedule();
|
||||
}
|
||||
|
||||
expect(fetchedAt).toHaveLength(TICKS + 1);
|
||||
const intervals = fetchedAt.slice(1).map((t, i) => t - fetchedAt[i]);
|
||||
expect(intervals).toEqual(new Array(TICKS).fill(PERIOD_MS));
|
||||
});
|
||||
|
||||
test("the scheduled tick fetches whatever the last fetch's latency was", async () => {
|
||||
// The alarm fires one period after the previous alarm, which is
|
||||
// `latency` short of one period since the fetch it caused completed.
|
||||
for (const latency of [200, 1000, 5000]) {
|
||||
clearStorage();
|
||||
jest.resetModules();
|
||||
storageStore[DELTA_STORAGE_KEY] = {
|
||||
blacklist: [],
|
||||
lastFetchTime: now - PERIOD_MS + latency,
|
||||
lastAttemptTime: now - PERIOD_MS,
|
||||
};
|
||||
const mod = require("../src/shared/phishingDomains");
|
||||
const fetchedAt = [];
|
||||
global.fetch = fetchStub(latency, fetchedAt);
|
||||
|
||||
await mod.refreshPhishingListOnSchedule();
|
||||
expect(fetchedAt).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
test("a worker wake inside the cache window still does not fetch", async () => {
|
||||
// The TTL is not removed, only taken off the scheduled path. Chrome
|
||||
// revives the worker every ~30 seconds and every revival runs the
|
||||
// startup path, so the TTL still has to keep that off the network.
|
||||
storageStore[DELTA_STORAGE_KEY] = {
|
||||
blacklist: [],
|
||||
lastFetchTime: now - PERIOD_MS + 5000,
|
||||
lastAttemptTime: now - PERIOD_MS,
|
||||
};
|
||||
const mod = require("../src/shared/phishingDomains");
|
||||
global.fetch = jest.fn();
|
||||
|
||||
await mod.initPhishingList();
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("phishing list timestamps that cannot be trusted", () => {
|
||||
let clockSpy;
|
||||
let now;
|
||||
|
||||
beforeEach(() => {
|
||||
clearStorage();
|
||||
jest.resetModules();
|
||||
now = Date.UTC(2026, 0, 1, 0, 0, 0);
|
||||
clockSpy = jest.spyOn(Date, "now").mockImplementation(() => now);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clockSpy.mockRestore();
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
function okFetch() {
|
||||
return jest.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ blacklist: ["recovered-scam-xyz.com"] }),
|
||||
}));
|
||||
}
|
||||
|
||||
// jest.resetModules() clears the call record of a jest.fn, and simulating
|
||||
// a worker restart is exactly that call. Anything counted across restarts
|
||||
// has to be counted outside the mock.
|
||||
function countingFetch(counter, response) {
|
||||
return async () => {
|
||||
counter.calls++;
|
||||
return response();
|
||||
};
|
||||
}
|
||||
|
||||
test("a lastFetchTime in the future is discarded rather than trusted", async () => {
|
||||
// Clock skew or a restored profile backup writes one. Every guard
|
||||
// measures `Date.now() - stamp` and only tests the lower bound, so a
|
||||
// stamp a year ahead would suppress updates for a year, and now that
|
||||
// the value is persisted it would outlive every worker.
|
||||
storageStore[DELTA_STORAGE_KEY] = {
|
||||
blacklist: ["poisoned-scam-xyz.com"],
|
||||
lastFetchTime: now + 365 * 24 * 60 * 60 * 1000,
|
||||
lastAttemptTime: 0,
|
||||
};
|
||||
const mod = require("../src/shared/phishingDomains");
|
||||
global.fetch = okFetch();
|
||||
|
||||
await mod.initPhishingList();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(mod.isPhishingDomain("recovered-scam-xyz.com")).toBe(true);
|
||||
// And the record it leaves behind is sane, so recovery is permanent.
|
||||
expect(
|
||||
storageStore[DELTA_STORAGE_KEY].lastFetchTime,
|
||||
).toBeLessThanOrEqual(now);
|
||||
});
|
||||
|
||||
test("a lastAttemptTime in the future does not suppress the retry", async () => {
|
||||
storageStore[DELTA_STORAGE_KEY] = {
|
||||
lastAttemptTime: now + 365 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
const mod = require("../src/shared/phishingDomains");
|
||||
global.fetch = okFetch();
|
||||
|
||||
await mod.initPhishingList();
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("an oversized delta does not re-download on every worker wake", async () => {
|
||||
// The delta and its freshness claim are both dropped, which is right,
|
||||
// but nothing then says a fetch just happened. Chrome cycles the
|
||||
// worker roughly every 30 seconds idle, so without the attempt stamp
|
||||
// this is a full blocklist download per wake, forever.
|
||||
const huge = [];
|
||||
for (let i = 0; i < 20000; i++) {
|
||||
huge.push(`oversize-scam-${i}-xyzxyzxyzxyzxyz.com`);
|
||||
}
|
||||
const counter = { calls: 0 };
|
||||
global.fetch = countingFetch(counter, () => ({
|
||||
ok: true,
|
||||
json: async () => ({ blacklist: huge }),
|
||||
}));
|
||||
|
||||
for (let wake = 0; wake < 4; wake++) {
|
||||
const revived = restartWorker();
|
||||
await revived.initPhishingList();
|
||||
now += 30 * 1000; // idle timeout, worker torn down and revived
|
||||
}
|
||||
|
||||
expect(counter.calls).toBe(1);
|
||||
expect(storageStore[DELTA_STORAGE_KEY].blacklist).toBeUndefined();
|
||||
expect(typeof storageStore[DELTA_STORAGE_KEY].lastAttemptTime).toBe(
|
||||
"number",
|
||||
);
|
||||
});
|
||||
|
||||
test("a failing fetch is not retried on every worker wake either", async () => {
|
||||
const counter = { calls: 0 };
|
||||
global.fetch = countingFetch(counter, () => ({
|
||||
ok: false,
|
||||
status: 503,
|
||||
}));
|
||||
|
||||
for (let wake = 0; wake < 4; wake++) {
|
||||
const revived = restartWorker();
|
||||
await revived.initPhishingList();
|
||||
now += 30 * 1000;
|
||||
}
|
||||
|
||||
expect(counter.calls).toBe(1);
|
||||
});
|
||||
|
||||
test("the retry floor expires, so a failure is not permanent", async () => {
|
||||
const {
|
||||
MIN_FETCH_ATTEMPT_INTERVAL_MS,
|
||||
} = require("../src/shared/phishingDomains");
|
||||
const counter = { calls: 0 };
|
||||
global.fetch = countingFetch(counter, () => ({
|
||||
ok: false,
|
||||
status: 503,
|
||||
}));
|
||||
|
||||
await restartWorker().initPhishingList();
|
||||
expect(counter.calls).toBe(1);
|
||||
|
||||
// Still inside the floor: no retry.
|
||||
now += MIN_FETCH_ATTEMPT_INTERVAL_MS - 1000;
|
||||
await restartWorker().initPhishingList();
|
||||
expect(counter.calls).toBe(1);
|
||||
|
||||
// Past it: the extension goes back to the network.
|
||||
now += 2000;
|
||||
await restartWorker().initPhishingList();
|
||||
expect(counter.calls).toBe(2);
|
||||
});
|
||||
|
||||
test("the scheduled tick ignores the retry floor", async () => {
|
||||
// The alarm period is far above the floor, but the floor exists to
|
||||
// throttle wakes, not the schedule.
|
||||
storageStore[DELTA_STORAGE_KEY] = { lastAttemptTime: now - 1000 };
|
||||
const mod = require("../src/shared/phishingDomains");
|
||||
global.fetch = okFetch();
|
||||
|
||||
await mod.refreshPhishingListOnSchedule();
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user