Downloads that never reach the header hook (form POST results, service-worker
responses, clicks Firefox routes straight to its downloader) surface here.
If one looks like the daemon's, offer it FIRST and only cancel + erase
Firefox's copy on {action:"take"} — a failed or slow offer can never leave
the user with nothing. blob:/data: downloads are left to Firefox (the daemon
can't fetch a blob URL).
- offered-urls.ts: short-lived, bounded TTL set of URLs the header hook has
already offered; the safety net checks it (via wasOffered) so nothing is
double-handled. Hook gains an onOffered hook to populate it.
- background/index.ts: both paths share one offer(), getCookies, rules
mirror, and OfferedUrls instance.
13 new tests incl. fail-open (offer rejects -> 'error', ignore ->
'offer_declined', cancel() throwing after take still returns 'taken').
101 tests green; web-ext lint clean.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y9RU58hD1BuwP82DySUHk
175 lines
6.7 KiB
TypeScript
175 lines
6.7 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { CaptureOfferParams, CaptureOfferResult, CaptureRules } from '../../src/shared/protocol/index.js';
|
|
import {
|
|
DownloadsSafetyNet,
|
|
type DownloadItem,
|
|
type DownloadsApiLike,
|
|
type DownloadsSafetyNetDeps,
|
|
} from '../../src/background/capture/downloads-api.js';
|
|
|
|
const RULES: CaptureRules = {
|
|
enabled: true,
|
|
monitoredExtensions: ['zip', 'iso'],
|
|
monitoredMimeTypes: ['application/octet-stream'],
|
|
minSizeBytes: 1024 * 1024,
|
|
excludedHosts: ['mail.example.com'],
|
|
rulesVersion: 1,
|
|
};
|
|
|
|
function item(over: Partial<DownloadItem> = {}): DownloadItem {
|
|
return {
|
|
id: 5,
|
|
url: 'https://cdn.example.com/pkg/tool.zip',
|
|
finalUrl: 'https://cdn.example.com/pkg/tool.zip',
|
|
referrer: 'https://example.com/get',
|
|
filename: '/home/u/Downloads/tool.zip',
|
|
mime: 'application/zip',
|
|
fileSize: -1,
|
|
totalBytes: 50 * 1024 * 1024,
|
|
state: 'in_progress',
|
|
...over,
|
|
};
|
|
}
|
|
|
|
interface Harness {
|
|
net: DownloadsSafetyNet;
|
|
offer: ReturnType<typeof vi.fn>;
|
|
cancel: ReturnType<typeof vi.fn>;
|
|
erase: ReturnType<typeof vi.fn>;
|
|
seen: CaptureOfferParams[];
|
|
}
|
|
|
|
function makeNet(over: Partial<DownloadsSafetyNetDeps> = {}): Harness {
|
|
const seen: CaptureOfferParams[] = [];
|
|
const offer =
|
|
(over.offer as ReturnType<typeof vi.fn> | undefined) ??
|
|
vi.fn(async (p: CaptureOfferParams): Promise<CaptureOfferResult> => {
|
|
seen.push(p);
|
|
return { action: 'take', taskId: 't1' };
|
|
});
|
|
const cancel = vi.fn(async () => undefined);
|
|
const erase = vi.fn(async () => [5]);
|
|
const downloads = { onCreated: { addListener: vi.fn(), removeListener: vi.fn() }, cancel, erase } as unknown as DownloadsApiLike;
|
|
const deps: DownloadsSafetyNetDeps = {
|
|
offer: offer as unknown as DownloadsSafetyNetDeps['offer'],
|
|
downloads,
|
|
getCookies: vi.fn(async () => [{ name: 'sid', value: 'x' }]) as unknown as DownloadsSafetyNetDeps['getCookies'],
|
|
getRules: () => RULES,
|
|
origin: 'moz-extension://uuid',
|
|
...over,
|
|
};
|
|
return { net: new DownloadsSafetyNet(deps), offer: offer as ReturnType<typeof vi.fn>, cancel, erase, seen };
|
|
}
|
|
|
|
describe('DownloadsSafetyNet.decide', () => {
|
|
it('offers a monitored-extension download and, on take, cancels + erases it', async () => {
|
|
const { net, cancel, erase, seen } = makeNet();
|
|
await expect(net.handle(item({ mime: 'application/zip' }))).resolves.toBe('taken');
|
|
expect(seen[0]).toMatchObject({
|
|
url: 'https://cdn.example.com/pkg/tool.zip',
|
|
method: 'GET',
|
|
filename: 'tool.zip',
|
|
contentLength: 50 * 1024 * 1024,
|
|
referrer: 'https://example.com/get',
|
|
origin: 'moz-extension://uuid',
|
|
});
|
|
expect(cancel).toHaveBeenCalledWith(5);
|
|
expect(erase).toHaveBeenCalledWith({ id: 5 });
|
|
});
|
|
|
|
it('offers on the size threshold alone', async () => {
|
|
const { net } = makeNet();
|
|
await expect(
|
|
net.handle(item({ filename: 'blob.bin', url: 'https://cdn.example.com/x', finalUrl: '', mime: 'application/x-thing', totalBytes: 9 * 1024 * 1024 })),
|
|
).resolves.toBe('taken');
|
|
});
|
|
|
|
it('leaves blob: downloads to Firefox', async () => {
|
|
const { net, offer } = makeNet();
|
|
await expect(net.handle(item({ url: 'blob:https://example.com/abc', finalUrl: '' }))).resolves.toBe('blob_or_data');
|
|
expect(offer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('skips an excluded host', async () => {
|
|
const { net } = makeNet();
|
|
await expect(
|
|
net.handle(item({ url: 'https://mail.example.com/a.zip', finalUrl: 'https://mail.example.com/a.zip' })),
|
|
).resolves.toBe('excluded_host');
|
|
});
|
|
|
|
it('skips a download that already completed or was interrupted', async () => {
|
|
const { net } = makeNet();
|
|
await expect(net.handle(item({ state: 'complete' }))).resolves.toBe('already_settled');
|
|
await expect(net.handle(item({ state: 'interrupted' }))).resolves.toBe('already_settled');
|
|
});
|
|
|
|
it('skips a URL the header hook already offered', async () => {
|
|
const { net, offer } = makeNet({ wasOffered: (u) => u === 'https://cdn.example.com/pkg/tool.zip' });
|
|
await expect(net.handle(item())).resolves.toBe('already_offered');
|
|
expect(offer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('skips when capture is disabled', async () => {
|
|
const { net } = makeNet({ getRules: () => ({ ...RULES, enabled: false }) });
|
|
await expect(net.handle(item())).resolves.toBe('capture_disabled');
|
|
});
|
|
|
|
it('skips a small file that matches no rule', async () => {
|
|
const { net } = makeNet();
|
|
await expect(
|
|
net.handle(item({ filename: 'note.txt', url: 'https://cdn.example.com/note.txt', finalUrl: '', mime: 'text/plain', totalBytes: 200 })),
|
|
).resolves.toBe('no_rule_matched');
|
|
});
|
|
});
|
|
|
|
describe('DownloadsSafetyNet — fail-open', () => {
|
|
it('offer() rejects → the Firefox download is left completely alone', async () => {
|
|
const { net, cancel, erase } = makeNet({
|
|
offer: vi.fn().mockRejectedValue(new Error('daemon down')) as unknown as DownloadsSafetyNetDeps['offer'],
|
|
});
|
|
await expect(net.handle(item())).resolves.toBe('error');
|
|
expect(cancel).not.toHaveBeenCalled();
|
|
expect(erase).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('offer() → ignore leaves the download alone', async () => {
|
|
const { net, cancel } = makeNet({
|
|
offer: vi.fn(async () => ({ action: 'ignore', reason: 'below_min_size' })) as unknown as DownloadsSafetyNetDeps['offer'],
|
|
});
|
|
await expect(net.handle(item())).resolves.toBe('offer_declined');
|
|
expect(cancel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('cancel() throwing after a take does not throw out of handle()', async () => {
|
|
const { net, erase } = makeNet();
|
|
(net as unknown as { deps: DownloadsSafetyNetDeps }).deps.downloads.cancel = vi
|
|
.fn()
|
|
.mockRejectedValue(new Error('already complete'));
|
|
await expect(net.handle(item())).resolves.toBe('taken');
|
|
expect(erase).toHaveBeenCalled(); // still tries to erase
|
|
});
|
|
|
|
it('still offers with an empty cookie list when cookie collection fails', async () => {
|
|
const { net, seen } = makeNet({
|
|
getCookies: vi.fn().mockRejectedValue(new Error('no perm')) as unknown as DownloadsSafetyNetDeps['getCookies'],
|
|
});
|
|
await expect(net.handle(item())).resolves.toBe('taken');
|
|
expect(seen[0]!.cookies).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('DownloadsSafetyNet.attach', () => {
|
|
it('registers and unregisters an onCreated listener', () => {
|
|
const add = vi.fn();
|
|
const remove = vi.fn();
|
|
const downloads = { onCreated: { addListener: add, removeListener: remove }, cancel: vi.fn(), erase: vi.fn() } as unknown as DownloadsApiLike;
|
|
const { net } = makeNet();
|
|
net.attach(downloads);
|
|
expect(add).toHaveBeenCalledOnce();
|
|
expect(() => net.attach(downloads)).toThrow(/already attached/);
|
|
net.detach();
|
|
expect(remove).toHaveBeenCalledOnce();
|
|
});
|
|
});
|