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 { 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; cancel: ReturnType; erase: ReturnType; seen: CaptureOfferParams[]; } function makeNet(over: Partial = {}): Harness { const seen: CaptureOfferParams[] = []; const offer = (over.offer as ReturnType | undefined) ?? vi.fn(async (p: CaptureOfferParams): Promise => { 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, 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(); }); });