/** * referer_proxy.test.mjs — coverage for the background worker's per-CDN-host * referer capture + DNR proxy-host learning, added in PR #5. * * The background worker is not itself a module, so the test transpiles it into * one (wrapping + top-level-statement `async function` transpile) under a mocked * `chrome` + `fetch`/`btoa` environment, then exercises the captured listeners * and the message handler directly. * * Covers the three gaps robot flagged: * (a) closing a tab must NOT wipe the host-keyed `goon_referers` global store, * (b) two hosts proxied concurrently must each keep their OWN referer (no race * that leaves host B's referer live for host A -> 412), * (c) the new referer-capture / proxy-host-learning / onRemoved paths run. */ import { describe, it } from './runner.mjs'; import { readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); const backgroundSource = readFileSync(join(__dirname, '../src/background/background.js'), 'utf8'); // Transpile the top-level-statement worker into an ES module. Wrapping in an // async IIFE makes its top-level `async function` declarations + `await`-using // nested functions legal inside a module. function buildModule(code) { return `(async () => {\n${code}\n})();\n`; } // A fresh mock of the chrome APIs the worker touches. `onBeforeSendHeaders`, // `onBeforeRequest`, and `tabs.onRemoved` capture their listener so the test can // drive them; `declarativeNetRequest.updateSessionRules` records the live rules. function makeChromeEnv() { const storage = {}; const handlers = { onMessage: [] }; let sessionRules = {}; const webRequest = { onBeforeSendHeaders: { addListener: (cb) => { handlers.onBeforeSendHeaders = cb; } }, onBeforeRequest: { addListener: (cb) => { handlers.onBeforeRequest = cb; } }, }; const chrome = { storage: { local: { get: async (keys) => { const out = {}; const arr = Array.isArray(keys) ? keys : (keys === undefined ? Object.keys(storage) : [keys]); for (const k of arr) if (k in storage) out[k] = storage[k]; return out; }, set: async (data) => { for (const [k, v] of Object.entries(data)) storage[k] = v; }, remove: async (keys) => { const arr = Array.isArray(keys) ? keys : [keys]; for (const k of arr) delete storage[k]; }, }, }, webRequest, tabs: { onRemoved: { addListener: (cb) => { handlers.onRemoved = cb; } }, }, runtime: { onMessage: { addListener: (cb) => { handlers.onMessage.push(cb); } } }, declarativeNetRequest: { updateSessionRules: async ({ removeRuleIds = [], addRules = [] } = {}) => { const rm = new Set(removeRuleIds); sessionRules = Object.fromEntries( Object.entries(sessionRules).filter(([id]) => !rm.has(id)) ); for (const r of addRules) sessionRules[r.id] = r; }, }, }; return { chrome, handlers, storage, getSessionRules: () => sessionRules }; } // Load the worker into a fresh environment. `data:` URLs are cached by Node, so // the module body (listener registration) would only run once; instead we write // a uniquely-named temp file per load so each call registers into its own env. let loadCounter = 0; async function loadBackground(env) { global.chrome = env.chrome; const file = join(tmpdir(), `goon_bg_${process.pid}_${loadCounter++}.mjs`); writeFileSync(file, buildModule(backgroundSource)); const module = await import(pathToFileURL(file).href); await module.default; return module; } function videoDetails(tabId, url, referer) { return { tabId, url, requestHeaders: referer ? [{ name: 'Referer', value: referer }] : [], }; } describe('PR#5: per-host referer capture + proxy race', () => { // (a) Blocking fix: a host-keyed `goon_referers` entry must outlive the close // of ANY tab — including a different tab. it('closing a tab does not wipe goon_referers captured for another host', async () => { const env = makeChromeEnv(); global.chrome = env.chrome; await loadBackground(env); // A live source tab captures the provider referer for its CDN host. const cap = env.handlers.onBeforeSendHeaders; if (!cap) throw new Error('onBeforeSendHeaders listener was not registered'); await cap(videoDetails(11, 'https://cdnA.example.com/hls/live/seg-1.ts', 'https://pageA.example.com/watch')); const rA = env.storage['goon_referers']?.['cdna.example.com']; if (!rA) throw new Error('referer was not captured for cdnA'); if (rA.referer !== 'https://pageA.example.com/watch') { throw new Error('captured referer wrong: ' + JSON.stringify(rA)); } // A DIFFERENT tab closes. The per-tab cleanup must NOT remove goon_referers. await env.handlers.onRemoved(22); const after = env.storage['goon_referers']; if (!after || !after['cdna.example.com']) { throw new Error('closing tab 22 wiped goon_referers — host A referer lost'); } if (after['cdna.example.com'].referer !== 'https://pageA.example.com/watch') { throw new Error('closing tab 22 corrupted the host A referer: ' + JSON.stringify(after)); } }); it('closing the capturing tab itself preserves the referer for later proxy use', async () => { const env = makeChromeEnv(); global.chrome = env.chrome; await loadBackground(env); await env.handlers.onBeforeSendHeaders( videoDetails(11, 'https://cdnB.example.com/video/x/master.m3u8', 'https://pageB.example.com/v') ); // The very tab that captured the referer now closes. await env.handlers.onRemoved(11); const after = env.storage['goon_referers']?.['cdnb.example.com']; if (!after) throw new Error('goon_referers for the capturing host was wiped on its own tab close'); if (after.referer !== 'https://pageB.example.com/v') { throw new Error('capturing-tab close corrupted referer: ' + JSON.stringify(after)); } }); // (b) Should-fix + coverage: two hosts proxied concurrently must each keep // their own referer. With a shared rule id + non-atomic install this regressed; // per-host rule ids + the per-host lock keep them distinct. it('concurrent proxy fetches for two hosts keep distinct per-host referer rules', async () => { const env = makeChromeEnv(); let fetchCalls = 0; global.chrome = env.chrome; global.fetch = async (url) => { fetchCalls++; return { ok: true, status: 200, headers: { get: () => 'application/vnd.apple.mpegurl' }, arrayBuffer: async () => new Uint8Array([0]).buffer, }; }; await loadBackground(env); // Two GOON_PROXY_FETCH messages for different CDN hosts, fired together. const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1]; let respA, respB; handler({ type: 'GOON_PROXY_FETCH', url: 'https://cdnA.example.com/hls/live/seg-1.ts', referer: 'https://pageA.example.com/watch' }, {}, (r) => { respA = r; }); handler({ type: 'GOON_PROXY_FETCH', url: 'https://cdnB.example.com/hls/live/seg-1.ts', referer: 'https://pageB.example.com/watch' }, {}, (r) => { respB = r; }); await new Promise(r => setTimeout(r, 30)); // let async sends + installs flush if (!respA || !respA.ok) throw new Error('proxy A failed: ' + JSON.stringify(respA)); if (!respB || !respB.ok) throw new Error('proxy B failed: ' + JSON.stringify(respB)); if (fetchCalls < 2) throw new Error('expected both proxy fetches to run, got ' + fetchCalls); // Each host must have its OWN installed DNR rule with its OWN referer — // host A must NOT be served host B's referer (the 412 regression). const rules = env.getSessionRules(); const ruleA = Object.values(rules).find(r => r.condition?.requestDomains?.includes('cdna.example.com')); const ruleB = Object.values(rules).find(r => r.condition?.requestDomains?.includes('cdnb.example.com')); if (!ruleA) throw new Error('no DNR rule installed for cdna; rules=' + JSON.stringify(rules)); if (!ruleB) throw new Error('no DNR rule installed for cdnb; rules=' + JSON.stringify(rules)); if (ruleA.id === ruleB.id) { throw new Error('cdnA and cdnB shared a DNR rule id -> concurrent race possible'); } const refA = ruleA.action.requestHeaders?.find(h => h.header.toLowerCase() === 'referer')?.value; const refB = ruleB.action.requestHeaders?.find(h => h.header.toLowerCase() === 'referer')?.value; if (refA !== 'https://pageA.example.com/watch') { throw new Error('cdnA served wrong referer: ' + refA); } if (refB !== 'https://pageB.example.com/watch') { throw new Error('cdnB served wrong referer: ' + refB); } if (refA === refB) throw new Error('both hosts got the same referer — cross-host bleed'); }); it('repeated proxy for the same host is idempotent (one rule, one install)', async () => { const env = makeChromeEnv(); let installs = 0; env.chrome.declarativeNetRequest.updateSessionRules = async ({ addRules = [] } = {}) => { for (const r of addRules) installs++; }; global.chrome = env.chrome; global.fetch = async () => ({ ok: true, status: 200, headers: { get: () => 'application/vnd.apple.mpegurl' }, arrayBuffer: async () => new Uint8Array([0]).buffer, }); await loadBackground(env); const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1]; const run = async (n) => { handler( { type: 'GOON_PROXY_FETCH', url: 'https://cdnA.example.com/hls/live/seg-1.ts', referer: 'https://pageA.example.com/watch' }, {}, () => {} ); await new Promise(r => setTimeout(r, 5)); }; await run(1); const installsAfterFirst = installs; await run(2); await run(3); // Same host + same referer should not re-add the rule on repeat fetches. if (installs !== installsAfterFirst) { throw new Error(`expected idempotent install, but re-added rule ${installs - installsAfterFirst} extra time(s)`); } }); });