// The blocking onHeadersReceived hook. // // Fail-open is the hard requirement (docs/05 §2, AGENT-EXT M1 DoD): daemon down, slow, or // erroring must never cost the user a download. Those cases are written first below. import { afterEach, describe, expect, it, vi } from 'vitest'; import type { CaptureOfferParams, CaptureOfferResult, CaptureRules } from '../../src/shared/protocol/index.js'; import { CaptureHook, type CaptureHookDeps, type HeadersReceivedWebRequest, type OnHeadersReceivedDetails, } from '../../src/background/capture/index.js'; const RULES: CaptureRules = { enabled: true, monitoredExtensions: ['zip', 'iso'], monitoredMimeTypes: ['application/octet-stream'], minSizeBytes: 1024 * 1024, excludedHosts: [], rulesVersion: 1, }; function detailsFor(over: Partial = {}): OnHeadersReceivedDetails { return { requestId: '42', url: 'https://cdn.example.com/files/ubuntu.iso', method: 'GET', type: 'other', statusCode: 200, tabId: 3, responseHeaders: [ { name: 'Content-Type', value: 'application/octet-stream' }, { name: 'Content-Length', value: String(900 * 1024 * 1024) }, ], documentUrl: 'https://example.com/releases', ...over, }; } interface Harness { hook: CaptureHook; offer: ReturnType; stash: { take: ReturnType; peek: ReturnType }; getCookies: ReturnType; } function makeHook(over: Partial = {}): Harness { const { offer: offerOver, getCookies: cookiesOver, stash: stashOver, ...rest } = over; const stashEntry = { requestId: '42', url: '', method: 'GET', tabId: 3, stashedAt: 0, headers: { referer: 'https://example.com/releases' } }; const stash = (stashOver as unknown as Harness['stash']) ?? { take: vi.fn(() => stashEntry), peek: vi.fn(() => stashEntry), }; const offer = (offerOver as unknown as ReturnType) ?? vi.fn(async (): Promise => ({ action: 'ignore', reason: 'type_not_monitored' })); const getCookies = (cookiesOver as unknown as ReturnType) ?? vi.fn(async () => [{ name: 'sid', value: 'abc', domain: '.example.com', secure: true }]); const deps: CaptureHookDeps = { offer: offer as unknown as CaptureHookDeps['offer'], stash: stash as unknown as CaptureHookDeps['stash'], getCookies: getCookies as unknown as CaptureHookDeps['getCookies'], getRules: () => RULES, origin: 'moz-extension://11111111-2222-3333-4444-555555555555', budgetMs: 40, ...rest, }; return { hook: new CaptureHook(deps), offer, stash, getCookies }; } afterEach(() => { vi.useRealTimers(); }); describe('CaptureHook — fail-open (written before the feature)', () => { it('daemon down: offer() rejects → Firefox downloads normally', async () => { const { hook } = makeHook({ offer: vi.fn().mockRejectedValue(new Error('transport is not connected')) }); await expect(hook.handle(detailsFor())).resolves.toEqual({}); }); it('daemon slow: offer() never settles → resolves to {} within the budget', async () => { const { hook } = makeHook({ offer: vi.fn(() => new Promise(() => {})), budgetMs: 30 }); const started = Date.now(); await expect(hook.handle(detailsFor())).resolves.toEqual({}); expect(Date.now() - started).toBeGreaterThanOrEqual(20); expect(Date.now() - started).toBeLessThan(300); }); it('daemon erroring: offer() rejects with a timeout-like error → {}', async () => { const err = Object.assign(new Error('capture.offer timed out after 750 ms'), { name: 'RpcTimeoutError' }); const { hook } = makeHook({ offer: vi.fn().mockRejectedValue(err) }); await expect(hook.handle(detailsFor())).resolves.toEqual({}); }); it('getRules() throws → {} and no offer attempted', async () => { const offer = vi.fn(); const { hook } = makeHook({ offer: offer as unknown as CaptureHookDeps['offer'], getRules: () => { throw new Error('rules not loaded'); }, }); await expect(hook.handle(detailsFor())).resolves.toEqual({}); expect(offer).not.toHaveBeenCalled(); }); it('the listener never rejects, whatever the details', async () => { const { hook } = makeHook(); // @ts-expect-error deliberately malformed await expect(hook.handle({})).resolves.toEqual({}); }); }); describe('CaptureHook — the happy paths', () => { it('shouldCapture says no → {} and the offer is not made, stash not consumed', async () => { const { hook, offer, stash } = makeHook({ getRules: () => ({ ...RULES, enabled: false }) }); await expect(hook.handle(detailsFor())).resolves.toEqual({}); expect(offer).not.toHaveBeenCalled(); expect(stash.take).not.toHaveBeenCalled(); }); it('offer → {action:"take"} cancels the browser download', async () => { const { hook, offer, stash } = makeHook({ offer: vi.fn(async () => ({ action: 'take', taskId: 't1' })) as unknown as CaptureHookDeps['offer'], }); await expect(hook.handle(detailsFor())).resolves.toEqual({ cancel: true }); expect(offer).toHaveBeenCalledOnce(); expect(stash.take).toHaveBeenCalledWith('42'); }); it('offer → {action:"ignore"} lets Firefox proceed', async () => { const { hook } = makeHook(); await expect(hook.handle(detailsFor())).resolves.toEqual({}); }); it('builds a well-formed capture.offer from details + stash + response headers', async () => { const seen: CaptureOfferParams[] = []; const { hook } = makeHook({ offer: (vi.fn(async (p: CaptureOfferParams) => { seen.push(p); return { action: 'take', taskId: 't' }; }) as unknown) as CaptureHookDeps['offer'], }); await hook.handle(detailsFor()); expect(seen[0]).toMatchObject({ url: 'https://cdn.example.com/files/ubuntu.iso', method: 'GET', tabUrl: 'https://example.com/releases', contentType: 'application/octet-stream', contentLength: 900 * 1024 * 1024, referrer: 'https://example.com/releases', origin: 'moz-extension://11111111-2222-3333-4444-555555555555', requestId: '42', filename: 'ubuntu.iso', }); expect(seen[0]!.cookies).toEqual([{ name: 'sid', value: 'abc', domain: '.example.com', secure: true }]); }); it('still offers when cookie collection fails, with an empty cookie list', async () => { const seen: CaptureOfferParams[] = []; const { hook } = makeHook({ offer: (async (p: CaptureOfferParams) => { seen.push(p); return { action: 'take', taskId: 't' }; }) as CaptureHookDeps['offer'], getCookies: (async () => { throw new Error('no cookies perm'); }) as CaptureHookDeps['getCookies'], }); await expect(hook.handle(detailsFor())).resolves.toEqual({ cancel: true }); expect(seen[0]!.cookies).toEqual([]); }); }); describe('CaptureHook.attach', () => { class FakeEvent { listeners: Array<(d: OnHeadersReceivedDetails) => unknown> = []; addListener = (l: (d: OnHeadersReceivedDetails) => unknown): void => void this.listeners.push(l); removeListener = (l: (d: OnHeadersReceivedDetails) => unknown): void => { this.listeners = this.listeners.filter((x) => x !== l); }; } it('registers a blocking responseHeaders listener and unregisters on detach', () => { const evt = new FakeEvent(); const wr = { onHeadersReceived: evt } as unknown as HeadersReceivedWebRequest; const { hook } = makeHook(); hook.attach(wr); expect(evt.listeners).toHaveLength(1); expect(() => hook.attach(wr)).toThrow(/already attached/); hook.detach(); expect(evt.listeners).toHaveLength(0); }); });