Compare commits
1 Commits
issue-271-
...
cafffe5ab9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cafffe5ab9 |
56
README.md
56
README.md
@@ -129,10 +129,11 @@ page, which it does not by default — `script/test-e2e` sets
|
|||||||
`PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1` for it. Because that flag is
|
`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
|
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
|
background worker's **own** startup request — the phishing blocklist fetch that
|
||||||
`src/background/index.js` issues unconditionally — to arrive in the route
|
`src/background/index.js` issues on startup, which on the suite's throwaway
|
||||||
handler, and aborts the entire suite if none does within 30 seconds
|
profile always happens because no previous fetch timestamp is persisted — to
|
||||||
(`tests/e2e/harness.js`). The check is passive on purpose: a synthetic probe
|
arrive in the route handler, and aborts the entire suite if none does within 30
|
||||||
fetched from inside the worker via `worker.evaluate()` was tried first and
|
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
|
rejected, because evaluating in an extension service worker that early kills the
|
||||||
worker outright, destroying the thing being measured. Observing traffic the
|
worker outright, destroying the thing being measured. Observing traffic the
|
||||||
extension already generates perturbs nothing. Losing the race fails closed — the
|
extension already generates perturbs nothing. Losing the race fails closed — the
|
||||||
@@ -192,9 +193,10 @@ src/
|
|||||||
styles/main.css — Tailwind source
|
styles/main.css — Tailwind source
|
||||||
views/ — one JS module per screen (home, send, approval, etc.)
|
views/ — one JS module per screen (home, send, approval, etc.)
|
||||||
shared/ — modules used by both popup and background
|
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
|
balances.js — ETH + ERC-20 balance fetching via RPC + Blockscout
|
||||||
constants.js — chain IDs, default RPC endpoint, ERC-20 ABI
|
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
|
prices.js — ETH/USD and token/USD via CoinDesk API
|
||||||
scamlist.js — known fraud contract addresses
|
scamlist.js — known fraud contract addresses
|
||||||
state.js — persisted state (extension storage)
|
state.js — persisted state (extension storage)
|
||||||
@@ -208,6 +210,39 @@ manifest/
|
|||||||
firefox.json — Manifest V2 for Firefox
|
firefox.json — Manifest V2 for Firefox
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Background scheduling
|
||||||
|
|
||||||
|
Chrome runs `src/background/index.js` as a Manifest V3 service worker, which the
|
||||||
|
browser terminates after roughly 30 seconds idle and re-evaluates from scratch
|
||||||
|
on the next event. Two consequences shape every recurring job in the background:
|
||||||
|
|
||||||
|
- `setInterval` and `setTimeout` are useless. They are destroyed with the
|
||||||
|
worker, so a job scheduled that way runs until the first idle period and never
|
||||||
|
again. Both recurring jobs — the 60-second balance refresh and the 24-hour
|
||||||
|
phishing blocklist refresh — are scheduled through the extension alarms API
|
||||||
|
(`src/shared/alarms.js`) instead. The browser holds the schedule and wakes the
|
||||||
|
worker to deliver it. Alarm periods are clamped to a one-minute minimum, so
|
||||||
|
the balance refresh is expressed as exactly one minute and nothing is silently
|
||||||
|
slowed down.
|
||||||
|
- Module-level variables do not survive either. Anything that must be remembered
|
||||||
|
across a restart goes in extension storage, including the timestamp of the
|
||||||
|
last phishing list fetch: without it a revived worker would either re-fetch on
|
||||||
|
every wake or, with a naive in-memory guard, never notice that an update is
|
||||||
|
due. `localStorage` does not exist in a service worker at all — the one
|
||||||
|
remaining user of it, `src/shared/ens.js`, runs only in the popup and is
|
||||||
|
marked as such.
|
||||||
|
|
||||||
|
The startup path (`ensureRecurringAlarms()` plus the phishing list init) runs on
|
||||||
|
`onInstalled`, on `onStartup`, and at the top level of the worker, so every way
|
||||||
|
the background context can start re-establishes the schedule. It is idempotent:
|
||||||
|
an alarm that already exists is left alone, because re-creating one restarts its
|
||||||
|
period and a busy extension would push the next fire out indefinitely.
|
||||||
|
|
||||||
|
Firefox uses Manifest V2 with a persistent background page, where timers would
|
||||||
|
survive. Both browsers are built from one bundle and both take the alarm path,
|
||||||
|
so there is a single code path to reason about; `"alarms"` is declared in both
|
||||||
|
`manifest/chrome.json` and `manifest/firefox.json`.
|
||||||
|
|
||||||
### UI Design Philosophy
|
### UI Design Philosophy
|
||||||
|
|
||||||
The UI is inspired by _Universal Paperclips_. It's deliberately minimal,
|
The UI is inspired by _Universal Paperclips_. It's deliberately minimal,
|
||||||
@@ -707,8 +742,11 @@ CoinDesk price API, and Blockscout API), AutistMask also contacts:
|
|||||||
blocklist is vendored into the extension at build time. At runtime, the
|
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
|
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
|
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
|
memory, keeping runtime memory usage small. The delta and the timestamp of the
|
||||||
localStorage if it is under 256 KiB.
|
fetch that produced it are persisted to extension storage if the record is
|
||||||
|
under 256 KiB; an oversized delta is dropped along with its timestamp, so the
|
||||||
|
next start fetches again rather than claiming freshness for data it no longer
|
||||||
|
holds.
|
||||||
- **Etherscan address labels**: When confirming a transaction, the extension
|
- **Etherscan address labels**: When confirming a transaction, the extension
|
||||||
performs a best-effort lookup of the recipient address on Etherscan to check
|
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
|
for phishing/scam labels. This is a direct page fetch with no API key; the
|
||||||
@@ -928,6 +966,10 @@ live list once every 24 hours and keeps only the delta (newly added domains not
|
|||||||
in the vendored list) in memory. This architecture keeps runtime memory usage
|
in the vendored list) in memory. This architecture keeps runtime memory usage
|
||||||
small while ensuring fresh coverage of new phishing domains.
|
small while ensuring fresh coverage of new phishing domains.
|
||||||
|
|
||||||
|
The 24-hour cadence is an alarm, not a timer, and the fetch timestamp lives in
|
||||||
|
extension storage rather than in a module variable — see
|
||||||
|
[Background scheduling](#background-scheduling) for why both are required.
|
||||||
|
|
||||||
When a dApp on a blocklisted domain requests a wallet connection, transaction
|
When a dApp on a blocklisted domain requests a wallet connection, transaction
|
||||||
approval, or signature, the approval popup displays a prominent red warning
|
approval, or signature, the approval popup displays a prominent red warning
|
||||||
banner alerting the user. The domain checker matches exact hostnames and all
|
banner alerting the user. The domain checker matches exact hostnames and all
|
||||||
|
|||||||
5
TODO.md
5
TODO.md
@@ -44,6 +44,11 @@ undefined identifiers, which is how
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-08-11: the balance refresh and the 24-hour phishing list refresh moved
|
||||||
|
from `setInterval` to the extension alarms API, with the phishing delta and
|
||||||
|
its fetch timestamp persisted to extension storage, so neither job dies with
|
||||||
|
the MV3 service worker
|
||||||
|
([#158](https://git.eeqj.de/sneak/AutistMask/issues/158)).
|
||||||
- 2026-08-11: `docs/README.md` rewritten against the code: no competitor names,
|
- 2026-08-11: `docs/README.md` rewritten against the code: no competitor names,
|
||||||
all five network destinations documented, password/Settings/Add Wallet
|
all five network destinations documented, password/Settings/Add Wallet
|
||||||
sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)).
|
sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)).
|
||||||
|
|||||||
@@ -130,10 +130,12 @@ live list to pick up newly added domains, keeping only the entries not already
|
|||||||
in the bundled copy (persisted locally if under 256 KiB). This endpoint is not
|
in the bundled copy (persisted locally if under 256 KiB). This endpoint is not
|
||||||
user-configurable.
|
user-configurable.
|
||||||
|
|
||||||
When it is contacted: once when the background script starts, and every 24 hours
|
When it is contacted: when the background script starts, if the last fetch was
|
||||||
after that. It is a plain download of a public file — nothing about you is sent,
|
more than 24 hours ago, and every 24 hours after that. The time of the last
|
||||||
but the host sees your IP address. If the fetch fails, the bundled copy is still
|
fetch is remembered across browser and background restarts, so restarting does
|
||||||
used.
|
not cause a re-download. It is a plain download of a public file — nothing about
|
||||||
|
you is sent, but the host sees your IP address. If the fetch fails, the bundled
|
||||||
|
copy is still used.
|
||||||
|
|
||||||
**Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia)
|
**Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"name": "AutistMask",
|
"name": "AutistMask",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Minimal Ethereum wallet for Chrome",
|
"description": "Minimal Ethereum wallet for Chrome",
|
||||||
"permissions": ["storage", "activeTab"],
|
"permissions": ["storage", "activeTab", "alarms"],
|
||||||
"host_permissions": ["<all_urls>"],
|
"host_permissions": ["<all_urls>"],
|
||||||
"action": {
|
"action": {
|
||||||
"default_popup": "src/popup/index.html"
|
"default_popup": "src/popup/index.html"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"name": "AutistMask",
|
"name": "AutistMask",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Minimal Ethereum wallet for Firefox",
|
"description": "Minimal Ethereum wallet for Firefox",
|
||||||
"permissions": ["storage", "activeTab", "<all_urls>"],
|
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
|
||||||
"browser_action": {
|
"browser_action": {
|
||||||
"default_popup": "src/popup/index.html"
|
"default_popup": "src/popup/index.html"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,8 +17,15 @@ const { verifySignedTx, verifySignature } = require("../shared/approvalVerify");
|
|||||||
const {
|
const {
|
||||||
isPhishingDomain,
|
isPhishingDomain,
|
||||||
updatePhishingList,
|
updatePhishingList,
|
||||||
startPeriodicRefresh,
|
initPhishingList,
|
||||||
} = require("../shared/phishingDomains");
|
} = require("../shared/phishingDomains");
|
||||||
|
const {
|
||||||
|
BALANCE_REFRESH_ALARM,
|
||||||
|
PHISHING_REFRESH_ALARM,
|
||||||
|
BALANCE_REFRESH_PERIOD_MINUTES,
|
||||||
|
ensureRecurringAlarms,
|
||||||
|
registerAlarmHandlers,
|
||||||
|
} = require("../shared/alarms");
|
||||||
|
|
||||||
const storageApi =
|
const storageApi =
|
||||||
typeof browser !== "undefined"
|
typeof browser !== "undefined"
|
||||||
@@ -591,7 +598,7 @@ async function broadcastAccountsChanged() {
|
|||||||
// Background balance refresh: every 60 seconds when the popup isn't open.
|
// Background balance refresh: every 60 seconds when the popup isn't open.
|
||||||
// When the popup IS open, its 10-second interval keeps lastBalanceRefresh
|
// When the popup IS open, its 10-second interval keeps lastBalanceRefresh
|
||||||
// fresh, so this naturally skips.
|
// fresh, so this naturally skips.
|
||||||
const BACKGROUND_REFRESH_INTERVAL = 60000;
|
const BACKGROUND_REFRESH_INTERVAL = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000;
|
||||||
|
|
||||||
async function backgroundRefresh() {
|
async function backgroundRefresh() {
|
||||||
await loadState();
|
await loadState();
|
||||||
@@ -609,12 +616,36 @@ async function backgroundRefresh() {
|
|||||||
await saveState();
|
await saveState();
|
||||||
}
|
}
|
||||||
|
|
||||||
setInterval(backgroundRefresh, BACKGROUND_REFRESH_INTERVAL);
|
// Both recurring jobs run off alarms, not timers. On Chrome MV3 this file is
|
||||||
|
// a service worker that the browser terminates after about 30 seconds idle,
|
||||||
|
// so a setInterval would only ever survive until the first idle period and
|
||||||
|
// module-level state does not outlive it. Alarms are held by the browser and
|
||||||
|
// wake the worker to deliver them.
|
||||||
|
registerAlarmHandlers({
|
||||||
|
[BALANCE_REFRESH_ALARM]: backgroundRefresh,
|
||||||
|
// Re-reads the persisted fetch timestamp first, so a tick that lands on a
|
||||||
|
// freshly revived worker neither re-fetches needlessly nor skips an
|
||||||
|
// overdue update.
|
||||||
|
[PHISHING_REFRESH_ALARM]: updatePhishingList,
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch the phishing domain blocklist delta on startup and refresh every 24h.
|
// Everything the background context needs re-established on start. This runs
|
||||||
// The vendored blocklist is bundled at build time; this fetches only new entries.
|
// on a fresh install, on browser startup, and on every revival of a
|
||||||
updatePhishingList();
|
// terminated worker, so it must be idempotent: ensureRecurringAlarms() only
|
||||||
startPeriodicRefresh();
|
// creates alarms that are missing, and initPhishingList() fetches only when
|
||||||
|
// the persisted timestamp says the list is stale.
|
||||||
|
function startBackgroundJobs() {
|
||||||
|
ensureRecurringAlarms();
|
||||||
|
initPhishingList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtime.onInstalled) {
|
||||||
|
runtime.onInstalled.addListener(startBackgroundJobs);
|
||||||
|
}
|
||||||
|
if (runtime.onStartup) {
|
||||||
|
runtime.onStartup.addListener(startBackgroundJobs);
|
||||||
|
}
|
||||||
|
startBackgroundJobs();
|
||||||
|
|
||||||
// When approval window is closed without a response, treat as rejection
|
// When approval window is closed without a response, treat as rejection
|
||||||
if (windowsApi && windowsApi.onRemoved) {
|
if (windowsApi && windowsApi.onRemoved) {
|
||||||
|
|||||||
100
src/shared/alarms.js
Normal file
100
src/shared/alarms.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
// Periodic scheduling for the background context.
|
||||||
|
//
|
||||||
|
// The Chrome MV3 service worker is terminated after roughly 30 seconds idle,
|
||||||
|
// which takes every setInterval/setTimeout with it. The extension alarms API
|
||||||
|
// is the mechanism that survives: the browser holds the schedule and wakes
|
||||||
|
// the worker to deliver onAlarm. Firefox MV2 runs a persistent background
|
||||||
|
// page where timers would survive, but alarms behave identically there, so
|
||||||
|
// both targets share this path and both manifests declare the "alarms"
|
||||||
|
// permission.
|
||||||
|
//
|
||||||
|
// Periods are whole minutes at or above the browser-enforced one-minute
|
||||||
|
// minimum, so nothing here is silently clamped to a slower cadence.
|
||||||
|
|
||||||
|
const BALANCE_REFRESH_ALARM = "autistmask-balance-refresh";
|
||||||
|
const PHISHING_REFRESH_ALARM = "autistmask-phishing-refresh";
|
||||||
|
|
||||||
|
const MIN_ALARM_PERIOD_MINUTES = 1;
|
||||||
|
const BALANCE_REFRESH_PERIOD_MINUTES = 1;
|
||||||
|
const PHISHING_REFRESH_PERIOD_MINUTES = 24 * 60;
|
||||||
|
|
||||||
|
// Resolved on use rather than captured at module load: the worker is torn
|
||||||
|
// down and re-evaluated repeatedly, and tests install a stub after requiring
|
||||||
|
// the module.
|
||||||
|
function alarmsApi() {
|
||||||
|
if (typeof browser !== "undefined" && browser.alarms) return browser.alarms;
|
||||||
|
if (typeof chrome !== "undefined" && chrome.alarms) return chrome.alarms;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an alarm if it does not already exist.
|
||||||
|
*
|
||||||
|
* The existence check is load-bearing: creating an alarm resets its schedule,
|
||||||
|
* and this runs on every worker wake. Creating unconditionally would push the
|
||||||
|
* next fire time out on every incoming message, so a busy extension would
|
||||||
|
* never see the alarm fire at all.
|
||||||
|
*
|
||||||
|
* @param {string} name
|
||||||
|
* @param {number} periodInMinutes
|
||||||
|
* @returns {Promise<boolean>} true if the alarm was created by this call.
|
||||||
|
*/
|
||||||
|
async function ensureAlarm(name, periodInMinutes) {
|
||||||
|
const api = alarmsApi();
|
||||||
|
if (!api) return false;
|
||||||
|
const period = Math.max(periodInMinutes, MIN_ALARM_PERIOD_MINUTES);
|
||||||
|
const existing = await api.get(name);
|
||||||
|
if (existing) return false;
|
||||||
|
api.create(name, {
|
||||||
|
periodInMinutes: period,
|
||||||
|
delayInMinutes: period,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure both recurring background jobs are scheduled. Safe to call on every
|
||||||
|
* worker start, on onInstalled and on onStartup.
|
||||||
|
*
|
||||||
|
* @returns {Promise<{balance: boolean, phishing: boolean}>} which alarms this
|
||||||
|
* call had to create.
|
||||||
|
*/
|
||||||
|
async function ensureRecurringAlarms() {
|
||||||
|
const balance = await ensureAlarm(
|
||||||
|
BALANCE_REFRESH_ALARM,
|
||||||
|
BALANCE_REFRESH_PERIOD_MINUTES,
|
||||||
|
);
|
||||||
|
const phishing = await ensureAlarm(
|
||||||
|
PHISHING_REFRESH_ALARM,
|
||||||
|
PHISHING_REFRESH_PERIOD_MINUTES,
|
||||||
|
);
|
||||||
|
return { balance, phishing };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register per-alarm handlers. One listener dispatches by alarm name so the
|
||||||
|
* worker only ever installs a single onAlarm listener.
|
||||||
|
*
|
||||||
|
* @param {Object<string, function>} handlers
|
||||||
|
* @returns {boolean} true if the listener was installed.
|
||||||
|
*/
|
||||||
|
function registerAlarmHandlers(handlers) {
|
||||||
|
const api = alarmsApi();
|
||||||
|
if (!api || !api.onAlarm) return false;
|
||||||
|
api.onAlarm.addListener((alarm) => {
|
||||||
|
const handler = handlers[alarm && alarm.name];
|
||||||
|
if (handler) handler();
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
BALANCE_REFRESH_ALARM,
|
||||||
|
PHISHING_REFRESH_ALARM,
|
||||||
|
MIN_ALARM_PERIOD_MINUTES,
|
||||||
|
BALANCE_REFRESH_PERIOD_MINUTES,
|
||||||
|
PHISHING_REFRESH_PERIOD_MINUTES,
|
||||||
|
ensureAlarm,
|
||||||
|
ensureRecurringAlarms,
|
||||||
|
registerAlarmHandlers,
|
||||||
|
};
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
// Cached ENS reverse resolution.
|
// Cached ENS reverse resolution.
|
||||||
// Resolves addresses to ENS names via ethers provider.lookupAddress(),
|
// Resolves addresses to ENS names via ethers provider.lookupAddress(),
|
||||||
// caching results in localStorage with a 12-hour TTL.
|
// 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 { getProvider } = require("./balances");
|
||||||
const { log } = require("./log");
|
const { log } = require("./log");
|
||||||
|
|||||||
@@ -8,8 +8,12 @@
|
|||||||
// The domain-checker checks the in-memory delta first (fresh/recent scam
|
// The domain-checker checks the in-memory delta first (fresh/recent scam
|
||||||
// sites), then falls back to the vendored list.
|
// sites), then falls back to the vendored list.
|
||||||
//
|
//
|
||||||
// If the delta is under 256 KiB it is persisted to localStorage so it
|
// If the delta and its fetch timestamp fit in 256 KiB they are persisted to
|
||||||
// survives extension/service-worker restarts.
|
// extension storage, so they survive termination of the MV3 service worker.
|
||||||
|
// Extension storage, not localStorage: localStorage does not exist in a
|
||||||
|
// service worker, so the previous persistence never ran on Chrome at all.
|
||||||
|
// The stored timestamp is what keeps a restarted worker from re-fetching on
|
||||||
|
// every wake while still noticing an overdue update.
|
||||||
|
|
||||||
const vendoredConfig = require("./phishingBlocklist.json");
|
const vendoredConfig = require("./phishingBlocklist.json");
|
||||||
|
|
||||||
@@ -17,7 +21,6 @@ const BLOCKLIST_URL =
|
|||||||
"https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json";
|
"https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json";
|
||||||
|
|
||||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
||||||
const DELTA_STORAGE_KEY = "phishing-delta";
|
const DELTA_STORAGE_KEY = "phishing-delta";
|
||||||
const MAX_DELTA_BYTES = 256 * 1024; // 256 KiB
|
const MAX_DELTA_BYTES = 256 * 1024; // 256 KiB
|
||||||
|
|
||||||
@@ -30,44 +33,79 @@ const vendoredBlacklist = new Set(
|
|||||||
let deltaBlacklist = new Set();
|
let deltaBlacklist = new Set();
|
||||||
let lastFetchTime = 0;
|
let lastFetchTime = 0;
|
||||||
let fetchPromise = null;
|
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.
|
* Load the persisted delta and its fetch timestamp from extension storage.
|
||||||
* Called once during module initialization in the background script.
|
* Runs once per worker lifetime; every entry point funnels through
|
||||||
|
* ensureDeltaLoaded() so a wake from termination restores state exactly once.
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
function loadDeltaFromStorage() {
|
async function loadDeltaFromStorage() {
|
||||||
|
const storage = storageApi();
|
||||||
|
if (!storage) return;
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(DELTA_STORAGE_KEY);
|
const result = await storage.get(DELTA_STORAGE_KEY);
|
||||||
if (!raw) return;
|
const data = result && result[DELTA_STORAGE_KEY];
|
||||||
const data = JSON.parse(raw);
|
if (!data) return;
|
||||||
if (data.blacklist && Array.isArray(data.blacklist)) {
|
if (Array.isArray(data.blacklist)) {
|
||||||
deltaBlacklist = new Set(
|
deltaBlacklist = new Set(
|
||||||
data.blacklist.map((d) => d.toLowerCase()),
|
data.blacklist.map((d) => d.toLowerCase()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (typeof data.lastFetchTime === "number") {
|
||||||
|
lastFetchTime = data.lastFetchTime;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// localStorage unavailable or corrupt — start empty
|
// Storage unavailable or corrupt — start empty and re-fetch.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureDeltaLoaded() {
|
||||||
|
if (!loadPromise) loadPromise = loadDeltaFromStorage();
|
||||||
|
return loadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist delta to localStorage if it fits within MAX_DELTA_BYTES.
|
* Persist the delta and its timestamp if they fit within MAX_DELTA_BYTES.
|
||||||
|
*
|
||||||
|
* The cap covers the whole record: when the delta is too large to keep, the
|
||||||
|
* timestamp goes with it, so the next worker start re-fetches rather than
|
||||||
|
* trusting a freshness claim for a delta it no longer holds.
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
function saveDeltaToStorage() {
|
async function saveDeltaToStorage() {
|
||||||
|
const storage = storageApi();
|
||||||
|
if (!storage) return;
|
||||||
try {
|
try {
|
||||||
const data = {
|
const data = {
|
||||||
blacklist: Array.from(deltaBlacklist),
|
blacklist: Array.from(deltaBlacklist),
|
||||||
|
lastFetchTime,
|
||||||
};
|
};
|
||||||
const json = JSON.stringify(data);
|
const json = JSON.stringify(data);
|
||||||
if (json.length < MAX_DELTA_BYTES) {
|
if (json.length < MAX_DELTA_BYTES) {
|
||||||
localStorage.setItem(DELTA_STORAGE_KEY, json);
|
await storage.set({ [DELTA_STORAGE_KEY]: data });
|
||||||
} else {
|
} else {
|
||||||
// Too large — remove stale key if present
|
// Too large — remove stale record if present
|
||||||
localStorage.removeItem(DELTA_STORAGE_KEY);
|
await storage.remove(DELTA_STORAGE_KEY);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// localStorage unavailable — skip silently
|
// Storage unavailable — skip silently
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +114,7 @@ function saveDeltaToStorage() {
|
|||||||
* Used for both live fetches and testing.
|
* Used for both live fetches and testing.
|
||||||
*
|
*
|
||||||
* @param {{ blacklist?: string[] }} config
|
* @param {{ blacklist?: string[] }} config
|
||||||
|
* @returns {Promise<void>} resolves once the delta has been persisted.
|
||||||
*/
|
*/
|
||||||
function loadConfig(config) {
|
function loadConfig(config) {
|
||||||
const liveBlacklist = (config.blacklist || []).map((d) => d.toLowerCase());
|
const liveBlacklist = (config.blacklist || []).map((d) => d.toLowerCase());
|
||||||
@@ -86,7 +125,7 @@ function loadConfig(config) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
lastFetchTime = Date.now();
|
lastFetchTime = Date.now();
|
||||||
saveDeltaToStorage();
|
return saveDeltaToStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,6 +150,11 @@ function hostnameVariants(hostname) {
|
|||||||
* Check if a hostname is on the phishing blocklist.
|
* Check if a hostname is on the phishing blocklist.
|
||||||
* Checks delta first (fresh/recent scam sites), then vendored list.
|
* 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.
|
* @param {string} hostname - The hostname to check.
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
@@ -127,11 +171,17 @@ function isPhishingDomain(hostname) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the latest blocklist and compute delta against vendored data.
|
* Fetch the latest blocklist and compute delta against vendored data.
|
||||||
* De-duplicates concurrent fetches. Results are cached for CACHE_TTL_MS.
|
* De-duplicates concurrent fetches. Results are cached for CACHE_TTL_MS,
|
||||||
|
* counted from the persisted timestamp so the cache outlives the worker.
|
||||||
*
|
*
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async function updatePhishingList() {
|
async function updatePhishingList() {
|
||||||
|
// A worker that has just been revived knows nothing until the persisted
|
||||||
|
// record is back in memory; without this the freshness check below would
|
||||||
|
// always see 0 and re-fetch on every wake.
|
||||||
|
await ensureDeltaLoaded();
|
||||||
|
|
||||||
// Skip if recently fetched
|
// Skip if recently fetched
|
||||||
if (Date.now() - lastFetchTime < CACHE_TTL_MS && lastFetchTime > 0) {
|
if (Date.now() - lastFetchTime < CACHE_TTL_MS && lastFetchTime > 0) {
|
||||||
return;
|
return;
|
||||||
@@ -145,7 +195,7 @@ async function updatePhishingList() {
|
|||||||
const resp = await fetch(BLOCKLIST_URL);
|
const resp = await fetch(BLOCKLIST_URL);
|
||||||
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||||
const config = await resp.json();
|
const config = await resp.json();
|
||||||
loadConfig(config);
|
await loadConfig(config);
|
||||||
} catch {
|
} catch {
|
||||||
// Silently fail — vendored list still provides coverage.
|
// Silently fail — vendored list still provides coverage.
|
||||||
// We'll retry next time.
|
// We'll retry next time.
|
||||||
@@ -158,12 +208,18 @@ async function updatePhishingList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start periodic refresh of the phishing list.
|
* Restore persisted state and fetch if the list is overdue.
|
||||||
* Should be called once from the background script on startup.
|
*
|
||||||
|
* 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() {
|
async function initPhishingList() {
|
||||||
if (refreshTimer) return;
|
await ensureDeltaLoaded();
|
||||||
refreshTimer = setInterval(updatePhishingList, REFRESH_INTERVAL_MS);
|
return updatePhishingList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -191,20 +247,18 @@ function _reset() {
|
|||||||
deltaBlacklist = new Set();
|
deltaBlacklist = new Set();
|
||||||
lastFetchTime = 0;
|
lastFetchTime = 0;
|
||||||
fetchPromise = null;
|
fetchPromise = null;
|
||||||
if (refreshTimer) {
|
loadPromise = null;
|
||||||
clearInterval(refreshTimer);
|
|
||||||
refreshTimer = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load persisted delta on module initialization
|
|
||||||
loadDeltaFromStorage();
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
isPhishingDomain,
|
isPhishingDomain,
|
||||||
updatePhishingList,
|
updatePhishingList,
|
||||||
startPeriodicRefresh,
|
initPhishingList,
|
||||||
|
loadDeltaFromStorage,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
|
CACHE_TTL_MS,
|
||||||
|
DELTA_STORAGE_KEY,
|
||||||
|
MAX_DELTA_BYTES,
|
||||||
getBlocklistSize,
|
getBlocklistSize,
|
||||||
getDeltaSize,
|
getDeltaSize,
|
||||||
hostnameVariants,
|
hostnameVariants,
|
||||||
|
|||||||
266
tests/alarms.test.js
Normal file
266
tests/alarms.test.js
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
// Scheduling for the background context.
|
||||||
|
//
|
||||||
|
// The Chrome MV3 service worker is terminated after roughly 30 seconds idle,
|
||||||
|
// so anything scheduled with setInterval/setTimeout dies with it. These tests
|
||||||
|
// pin the recurring jobs to the alarms API and to the re-registration path a
|
||||||
|
// revived worker runs.
|
||||||
|
|
||||||
|
function makeAlarmsStub() {
|
||||||
|
const alarms = new Map();
|
||||||
|
const listeners = [];
|
||||||
|
const stub = {
|
||||||
|
created: [],
|
||||||
|
alarms,
|
||||||
|
create: jest.fn((name, info) => {
|
||||||
|
stub.created.push({ name, info });
|
||||||
|
alarms.set(name, { name, ...info });
|
||||||
|
}),
|
||||||
|
get: jest.fn(async (name) => alarms.get(name)),
|
||||||
|
clear: jest.fn(async (name) => alarms.delete(name)),
|
||||||
|
onAlarm: {
|
||||||
|
addListener: jest.fn((fn) => listeners.push(fn)),
|
||||||
|
},
|
||||||
|
fire: (name) => {
|
||||||
|
for (const fn of listeners) fn({ name });
|
||||||
|
},
|
||||||
|
listenerCount: () => listeners.length,
|
||||||
|
};
|
||||||
|
return stub;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("alarms module", () => {
|
||||||
|
let alarmsStub;
|
||||||
|
let alarmsMod;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
alarmsStub = makeAlarmsStub();
|
||||||
|
global.chrome = { alarms: alarmsStub };
|
||||||
|
alarmsMod = require("../src/shared/alarms");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete global.chrome;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ensureRecurringAlarms schedules both recurring jobs", async () => {
|
||||||
|
const created = await alarmsMod.ensureRecurringAlarms();
|
||||||
|
expect(created).toEqual({ balance: true, phishing: true });
|
||||||
|
|
||||||
|
const names = alarmsStub.created.map((c) => c.name).sort();
|
||||||
|
expect(names).toEqual(
|
||||||
|
[
|
||||||
|
alarmsMod.BALANCE_REFRESH_ALARM,
|
||||||
|
alarmsMod.PHISHING_REFRESH_ALARM,
|
||||||
|
].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the balance refresh keeps its 60-second cadence", async () => {
|
||||||
|
await alarmsMod.ensureRecurringAlarms();
|
||||||
|
const balance = alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM);
|
||||||
|
expect(balance.periodInMinutes).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the phishing refresh keeps its 24-hour cadence", async () => {
|
||||||
|
await alarmsMod.ensureRecurringAlarms();
|
||||||
|
const phishing = alarmsStub.alarms.get(
|
||||||
|
alarmsMod.PHISHING_REFRESH_ALARM,
|
||||||
|
);
|
||||||
|
expect(phishing.periodInMinutes).toBe(24 * 60);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no period is below the browser-enforced minimum", async () => {
|
||||||
|
// A period under one minute is silently clamped by the browser, so a
|
||||||
|
// request for one would mean the documented cadence is not the real
|
||||||
|
// one. Every period must be a whole minute at or above the minimum.
|
||||||
|
await alarmsMod.ensureRecurringAlarms();
|
||||||
|
for (const { info } of alarmsStub.created) {
|
||||||
|
expect(info.periodInMinutes).toBeGreaterThanOrEqual(
|
||||||
|
alarmsMod.MIN_ALARM_PERIOD_MINUTES,
|
||||||
|
);
|
||||||
|
expect(Number.isInteger(info.periodInMinutes)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a revived worker does not reset an existing alarm's schedule", async () => {
|
||||||
|
await alarmsMod.ensureRecurringAlarms();
|
||||||
|
expect(alarmsStub.create).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Every wake re-runs the startup path. Re-creating an alarm restarts
|
||||||
|
// its period, so a busy extension would push the next fire out
|
||||||
|
// forever and the job would never run.
|
||||||
|
const again = await alarmsMod.ensureRecurringAlarms();
|
||||||
|
expect(again).toEqual({ balance: false, phishing: false });
|
||||||
|
expect(alarmsStub.create).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a missing alarm is re-created on the next start", async () => {
|
||||||
|
await alarmsMod.ensureRecurringAlarms();
|
||||||
|
await alarmsStub.clear(alarmsMod.BALANCE_REFRESH_ALARM);
|
||||||
|
|
||||||
|
const again = await alarmsMod.ensureRecurringAlarms();
|
||||||
|
expect(again).toEqual({ balance: true, phishing: false });
|
||||||
|
expect(
|
||||||
|
alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handlers are dispatched by alarm name from one listener", () => {
|
||||||
|
const balance = jest.fn();
|
||||||
|
const phishing = jest.fn();
|
||||||
|
expect(
|
||||||
|
alarmsMod.registerAlarmHandlers({
|
||||||
|
[alarmsMod.BALANCE_REFRESH_ALARM]: balance,
|
||||||
|
[alarmsMod.PHISHING_REFRESH_ALARM]: phishing,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(alarmsStub.listenerCount()).toBe(1);
|
||||||
|
|
||||||
|
alarmsStub.fire(alarmsMod.BALANCE_REFRESH_ALARM);
|
||||||
|
expect(balance).toHaveBeenCalledTimes(1);
|
||||||
|
expect(phishing).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
alarmsStub.fire(alarmsMod.PHISHING_REFRESH_ALARM);
|
||||||
|
expect(phishing).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
alarmsStub.fire("some-other-extension-alarm");
|
||||||
|
expect(balance).toHaveBeenCalledTimes(1);
|
||||||
|
expect(phishing).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Firefox MV2 gets the same treatment via browser.alarms", async () => {
|
||||||
|
// Both targets are built from one bundle. MV2 has a persistent
|
||||||
|
// background page, but it takes the alarm path too, so the schedule
|
||||||
|
// is the same code on both browsers.
|
||||||
|
jest.resetModules();
|
||||||
|
const firefoxAlarms = makeAlarmsStub();
|
||||||
|
global.browser = { alarms: firefoxAlarms };
|
||||||
|
try {
|
||||||
|
const mod = require("../src/shared/alarms");
|
||||||
|
const created = await mod.ensureRecurringAlarms();
|
||||||
|
expect(created).toEqual({ balance: true, phishing: true });
|
||||||
|
expect(firefoxAlarms.created).toHaveLength(2);
|
||||||
|
// The Chrome stub must not have been touched.
|
||||||
|
expect(alarmsStub.create).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
delete global.browser;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a context without the alarms API degrades instead of throwing", async () => {
|
||||||
|
jest.resetModules();
|
||||||
|
delete global.chrome;
|
||||||
|
const mod = require("../src/shared/alarms");
|
||||||
|
await expect(mod.ensureRecurringAlarms()).resolves.toEqual({
|
||||||
|
balance: false,
|
||||||
|
phishing: false,
|
||||||
|
});
|
||||||
|
expect(mod.registerAlarmHandlers({})).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("background worker scheduling", () => {
|
||||||
|
let alarmsStub;
|
||||||
|
let timers;
|
||||||
|
|
||||||
|
function loadBackground() {
|
||||||
|
const storageStore = {};
|
||||||
|
alarmsStub = makeAlarmsStub();
|
||||||
|
const listeners = { onInstalled: [], onStartup: [] };
|
||||||
|
global.chrome = {
|
||||||
|
alarms: alarmsStub,
|
||||||
|
storage: {
|
||||||
|
local: {
|
||||||
|
get: async (key) =>
|
||||||
|
Object.prototype.hasOwnProperty.call(storageStore, key)
|
||||||
|
? { [key]: storageStore[key] }
|
||||||
|
: {},
|
||||||
|
set: async (items) => Object.assign(storageStore, items),
|
||||||
|
remove: async (key) => {
|
||||||
|
delete storageStore[key];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
onMessage: { addListener: jest.fn() },
|
||||||
|
onConnect: { addListener: jest.fn() },
|
||||||
|
onInstalled: {
|
||||||
|
addListener: jest.fn((fn) =>
|
||||||
|
listeners.onInstalled.push(fn),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
onStartup: {
|
||||||
|
addListener: jest.fn((fn) => listeners.onStartup.push(fn)),
|
||||||
|
},
|
||||||
|
getURL: (p) => "chrome-extension://test/" + p,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
windows: {
|
||||||
|
onRemoved: { addListener: jest.fn() },
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
|
tabs: { query: jest.fn(), sendMessage: jest.fn() },
|
||||||
|
action: { setPopup: jest.fn() },
|
||||||
|
};
|
||||||
|
global.fetch = jest.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ blacklist: [] }),
|
||||||
|
}));
|
||||||
|
jest.resetModules();
|
||||||
|
require("../src/background/index");
|
||||||
|
return listeners;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
timers = {
|
||||||
|
setInterval: jest
|
||||||
|
.spyOn(global, "setInterval")
|
||||||
|
.mockImplementation(() => 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
timers.setInterval.mockRestore();
|
||||||
|
delete global.chrome;
|
||||||
|
delete global.fetch;
|
||||||
|
jest.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("startup schedules the recurring jobs as alarms, not timers", async () => {
|
||||||
|
loadBackground();
|
||||||
|
// Let the startup path's promises settle.
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
const names = alarmsStub.created.map((c) => c.name).sort();
|
||||||
|
const {
|
||||||
|
BALANCE_REFRESH_ALARM,
|
||||||
|
PHISHING_REFRESH_ALARM,
|
||||||
|
} = require("../src/shared/alarms");
|
||||||
|
expect(names).toEqual(
|
||||||
|
[BALANCE_REFRESH_ALARM, PHISHING_REFRESH_ALARM].sort(),
|
||||||
|
);
|
||||||
|
expect(timers.setInterval).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an onAlarm listener is installed on startup", async () => {
|
||||||
|
loadBackground();
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
expect(alarmsStub.listenerCount()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("onInstalled and onStartup both re-establish the schedule", async () => {
|
||||||
|
const listeners = loadBackground();
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
expect(listeners.onInstalled).toHaveLength(1);
|
||||||
|
expect(listeners.onStartup).toHaveLength(1);
|
||||||
|
|
||||||
|
// A browser start after the alarms were dropped must put them back.
|
||||||
|
alarmsStub.alarms.clear();
|
||||||
|
alarmsStub.created.length = 0;
|
||||||
|
listeners.onStartup[0]();
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
expect(alarmsStub.created).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,24 @@
|
|||||||
// Provide a localStorage mock for Node.js test environment.
|
// Extension storage stub for the Node test environment. The module resolves
|
||||||
// Must be set before requiring the module since it calls loadDeltaFromStorage()
|
// the storage API on use, so this only has to exist before the first call.
|
||||||
// at module load time.
|
// Values round-trip through JSON the way structured cloning would, so a test
|
||||||
const localStorageStore = {};
|
// cannot pass by holding a live reference to the module's own array.
|
||||||
global.localStorage = {
|
const storageStore = {};
|
||||||
getItem: (key) =>
|
global.chrome = {
|
||||||
Object.prototype.hasOwnProperty.call(localStorageStore, key)
|
storage: {
|
||||||
? localStorageStore[key]
|
local: {
|
||||||
: null,
|
get: async (key) =>
|
||||||
setItem: (key, value) => {
|
Object.prototype.hasOwnProperty.call(storageStore, key)
|
||||||
localStorageStore[key] = String(value);
|
? { [key]: JSON.parse(JSON.stringify(storageStore[key])) }
|
||||||
},
|
: {},
|
||||||
removeItem: (key) => {
|
set: async (items) => {
|
||||||
delete localStorageStore[key];
|
for (const [key, value] of Object.entries(items)) {
|
||||||
|
storageStore[key] = JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
remove: async (key) => {
|
||||||
|
delete storageStore[key];
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -21,19 +28,23 @@ const {
|
|||||||
getBlocklistSize,
|
getBlocklistSize,
|
||||||
getDeltaSize,
|
getDeltaSize,
|
||||||
hostnameVariants,
|
hostnameVariants,
|
||||||
|
DELTA_STORAGE_KEY,
|
||||||
_reset,
|
_reset,
|
||||||
_getVendoredBlacklistSize,
|
_getVendoredBlacklistSize,
|
||||||
_getDeltaBlacklist,
|
_getDeltaBlacklist,
|
||||||
} = require("../src/shared/phishingDomains");
|
} = require("../src/shared/phishingDomains");
|
||||||
|
|
||||||
|
function clearStorage() {
|
||||||
|
for (const key of Object.keys(storageStore)) {
|
||||||
|
delete storageStore[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reset delta state before each test to avoid cross-test contamination.
|
// Reset delta state before each test to avoid cross-test contamination.
|
||||||
// Note: vendored sets are immutable and always present.
|
// Note: vendored sets are immutable and always present.
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
_reset();
|
_reset();
|
||||||
// Clear localStorage mock between tests
|
clearStorage();
|
||||||
for (const key of Object.keys(localStorageStore)) {
|
|
||||||
delete localStorageStore[key];
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("phishingDomains", () => {
|
describe("phishingDomains", () => {
|
||||||
@@ -169,15 +180,34 @@ describe("phishingDomains", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("localStorage persistence", () => {
|
describe("extension storage persistence", () => {
|
||||||
test("saveDeltaToStorage persists delta under 256KiB", () => {
|
test("delta is persisted to extension storage, not localStorage", async () => {
|
||||||
loadConfig({
|
await loadConfig({
|
||||||
blacklist: ["persisted-scam-xyz.com"],
|
blacklist: ["persisted-scam-xyz.com"],
|
||||||
});
|
});
|
||||||
const stored = localStorage.getItem("phishing-delta");
|
const stored = storageStore[DELTA_STORAGE_KEY];
|
||||||
expect(stored).not.toBeNull();
|
expect(stored).toBeDefined();
|
||||||
const data = JSON.parse(stored);
|
expect(stored.blacklist).toContain("persisted-scam-xyz.com");
|
||||||
expect(data.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", () => {
|
test("delta is cleared on _reset", () => {
|
||||||
@@ -203,3 +233,98 @@ describe("phishingDomains", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The MV3 service worker is torn down when idle and re-evaluated on the next
|
||||||
|
// event, which wipes every module-level variable. Re-requiring the module
|
||||||
|
// with the module registry reset is exactly that: fresh in-memory state, same
|
||||||
|
// extension storage underneath.
|
||||||
|
describe("phishing list across a service worker restart", () => {
|
||||||
|
function restartWorker() {
|
||||||
|
jest.resetModules();
|
||||||
|
return require("../src/shared/phishingDomains");
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearStorage();
|
||||||
|
jest.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete global.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a revived worker restores the persisted delta without re-fetching", async () => {
|
||||||
|
const first = require("../src/shared/phishingDomains");
|
||||||
|
await first.loadConfig({ blacklist: ["restart-scam-xyz.com"] });
|
||||||
|
|
||||||
|
const revived = restartWorker();
|
||||||
|
// Nothing in memory yet — this is a brand new module instance.
|
||||||
|
expect(revived.getDeltaSize()).toBe(0);
|
||||||
|
|
||||||
|
global.fetch = jest.fn();
|
||||||
|
await revived.initPhishingList();
|
||||||
|
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
expect(revived.getDeltaSize()).toBe(1);
|
||||||
|
expect(revived.isPhishingDomain("restart-scam-xyz.com")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repeated wakes inside the cache window never re-fetch", async () => {
|
||||||
|
const first = require("../src/shared/phishingDomains");
|
||||||
|
await first.loadConfig({ blacklist: ["no-storm-scam-xyz.com"] });
|
||||||
|
|
||||||
|
global.fetch = jest.fn();
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const revived = restartWorker();
|
||||||
|
await revived.initPhishingList();
|
||||||
|
}
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a persisted timestamp older than the TTL causes a fetch on startup", async () => {
|
||||||
|
const first = require("../src/shared/phishingDomains");
|
||||||
|
await first.loadConfig({ blacklist: ["stale-scam-xyz.com"] });
|
||||||
|
|
||||||
|
// Age the persisted record past the 24-hour TTL.
|
||||||
|
storageStore[first.DELTA_STORAGE_KEY].lastFetchTime =
|
||||||
|
Date.now() - first.CACHE_TTL_MS - 1000;
|
||||||
|
|
||||||
|
const revived = restartWorker();
|
||||||
|
global.fetch = jest.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ blacklist: ["refreshed-scam-xyz.com"] }),
|
||||||
|
}));
|
||||||
|
await revived.initPhishingList();
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(revived.isPhishingDomain("refreshed-scam-xyz.com")).toBe(true);
|
||||||
|
expect(revived.isPhishingDomain("stale-scam-xyz.com")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a first start with nothing persisted fetches immediately", async () => {
|
||||||
|
const fresh = restartWorker();
|
||||||
|
global.fetch = jest.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ blacklist: ["first-run-scam-xyz.com"] }),
|
||||||
|
}));
|
||||||
|
await fresh.initPhishingList();
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fresh.isPhishingDomain("first-run-scam-xyz.com")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updatePhishingList honours the persisted timestamp on its own", async () => {
|
||||||
|
// The alarm handler calls updatePhishingList() directly, so it must
|
||||||
|
// load persisted state itself rather than relying on the startup path
|
||||||
|
// having finished first.
|
||||||
|
const first = require("../src/shared/phishingDomains");
|
||||||
|
await first.loadConfig({ blacklist: ["alarm-tick-scam-xyz.com"] });
|
||||||
|
|
||||||
|
const revived = restartWorker();
|
||||||
|
global.fetch = jest.fn();
|
||||||
|
await revived.updatePhishingList();
|
||||||
|
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
expect(revived.isPhishingDomain("alarm-tick-scam-xyz.com")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user