// 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]; } } // 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); }); }); }); // 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); }); });