// 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]; }, }, }, }; const { isPhishingDomain, loadConfig, 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(); clearStorage(); }); describe("phishingDomains", () => { describe("vendored blocklist", () => { test("vendored blacklist is loaded from bundled JSON", () => { // The vendored blocklist should have a large number of entries expect(_getVendoredBlacklistSize()).toBeGreaterThan(100000); }); test("detects domains from vendored blacklist", () => { // These are well-known phishing domains in the vendored list expect(isPhishingDomain("hopprotocol.pro")).toBe(true); expect(isPhishingDomain("blast-pools.pages.dev")).toBe(true); }); test("getBlocklistSize includes vendored entries", () => { expect(getBlocklistSize()).toBeGreaterThan(100000); }); }); describe("hostnameVariants", () => { test("returns exact hostname plus parent domains", () => { const variants = hostnameVariants("sub.evil.com"); expect(variants).toEqual(["sub.evil.com", "evil.com"]); }); test("returns just the hostname for a bare domain", () => { const variants = hostnameVariants("example.com"); expect(variants).toEqual(["example.com"]); }); test("handles deep subdomain chains", () => { const variants = hostnameVariants("a.b.c.d.com"); expect(variants).toEqual([ "a.b.c.d.com", "b.c.d.com", "c.d.com", "d.com", ]); }); test("lowercases hostnames", () => { const variants = hostnameVariants("Evil.COM"); expect(variants).toEqual(["evil.com"]); }); }); describe("delta computation via loadConfig", () => { test("loadConfig computes delta of new entries not in vendored list", () => { loadConfig({ blacklist: [ "brand-new-scam-site-xyz123.com", "hopprotocol.pro", // already in vendored ], }); // Only the new domain should be in the delta expect( _getDeltaBlacklist().has("brand-new-scam-site-xyz123.com"), ).toBe(true); expect(_getDeltaBlacklist().has("hopprotocol.pro")).toBe(false); expect(getDeltaSize()).toBe(1); }); test("re-loading config replaces previous delta", () => { loadConfig({ blacklist: ["first-scam-xyz.com"], }); expect(isPhishingDomain("first-scam-xyz.com")).toBe(true); loadConfig({ blacklist: ["second-scam-xyz.com"], }); expect(isPhishingDomain("first-scam-xyz.com")).toBe(false); expect(isPhishingDomain("second-scam-xyz.com")).toBe(true); }); test("getBlocklistSize includes both vendored and delta", () => { const baseSize = getBlocklistSize(); loadConfig({ blacklist: ["delta-only-scam-xyz.com"], }); expect(getBlocklistSize()).toBe(baseSize + 1); }); }); describe("isPhishingDomain with delta + vendored", () => { test("detects domain from delta blacklist", () => { loadConfig({ blacklist: ["fresh-scam-xyz.com"], }); expect(isPhishingDomain("fresh-scam-xyz.com")).toBe(true); }); test("detects domain from vendored blacklist", () => { // No delta loaded — vendored still works expect(isPhishingDomain("hopprotocol.pro")).toBe(true); }); test("returns false for clean domains", () => { expect(isPhishingDomain("etherscan.io")).toBe(false); expect(isPhishingDomain("example.com")).toBe(false); }); test("detects subdomain of blacklisted domain (vendored)", () => { expect(isPhishingDomain("app.hopprotocol.pro")).toBe(true); }); test("detects subdomain of blacklisted domain (delta)", () => { loadConfig({ blacklist: ["delta-phish-xyz.com"], }); expect(isPhishingDomain("sub.delta-phish-xyz.com")).toBe(true); }); test("case-insensitive matching", () => { loadConfig({ blacklist: ["Delta-Scam-XYZ.COM"], }); expect(isPhishingDomain("delta-scam-xyz.com")).toBe(true); expect(isPhishingDomain("DELTA-SCAM-XYZ.COM")).toBe(true); }); test("returns false for empty/null hostname", () => { expect(isPhishingDomain("")).toBe(false); expect(isPhishingDomain(null)).toBe(false); }); test("handles config with no blacklist key", () => { loadConfig({}); expect(getDeltaSize()).toBe(0); // Vendored list still works expect(isPhishingDomain("hopprotocol.pro")).toBe(true); }); }); describe("extension storage persistence", () => { test("delta is persisted to extension storage, not localStorage", async () => { await loadConfig({ blacklist: ["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", () => { loadConfig({ blacklist: ["temp-scam-xyz.com"], }); expect(getDeltaSize()).toBe(1); _reset(); expect(getDeltaSize()).toBe(0); }); }); describe("real-world blocklist patterns", () => { test("detects known phishing domains from vendored list", () => { expect(isPhishingDomain("uniswap-trade.web.app")).toBe(true); expect(isPhishingDomain("hopprotocol.pro")).toBe(true); expect(isPhishingDomain("blast-pools.pages.dev")).toBe(true); }); test("does not flag legitimate domains", () => { expect(isPhishingDomain("opensea.io")).toBe(false); expect(isPhishingDomain("etherscan.io")).toBe(false); }); }); }); 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); }); });