Compare commits
1 Commits
issue-275-
...
b114151116
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b114151116 |
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