For a response shouldCapture() likes: gather cookies, offer to the daemon
under a hard 750 ms budget, and return {cancel:true} ONLY on an explicit
{action:"take"}. Every other outcome — shouldCapture says no, daemon down /
slow / erroring, cookies fail, anything throws — resolves to {} and Firefox
downloads normally.
Fail-open tests written first (tests/capture/hook.test.ts): offer() rejects,
offer() never settles (resolves within budget), timeout-shaped rejection,
getRules() throws, malformed details. Plus the happy paths and a check that
the capture.offer payload is well-formed from details + stash + headers.
background/index.ts: wire the stash + hook onto browser.webRequest, mirror
capture rules via capture.getRules on connect and on a capture.* settings
change; DEFAULT_CAPTURE_RULES (enabled:false) until the first mirror lands.
84 tests green; web-ext lint clean.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y9RU58hD1BuwP82DySUHk
200 lines
7.6 KiB
TypeScript
200 lines
7.6 KiB
TypeScript
// 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> = {}): 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<typeof vi.fn>;
|
|
stash: { take: ReturnType<typeof vi.fn>; peek: ReturnType<typeof vi.fn> };
|
|
getCookies: ReturnType<typeof vi.fn>;
|
|
}
|
|
|
|
function makeHook(over: Partial<CaptureHookDeps> = {}): 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<typeof vi.fn>) ??
|
|
vi.fn(async (): Promise<CaptureOfferResult> => ({ action: 'ignore', reason: 'type_not_monitored' }));
|
|
const getCookies =
|
|
(cookiesOver as unknown as ReturnType<typeof vi.fn>) ??
|
|
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<never>(() => {})), 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);
|
|
});
|
|
});
|