ext: capture/downloads-api.ts — downloads.onCreated safety net
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
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
// The downloads.onCreated safety net.
|
||||
//
|
||||
// Some downloads never reach the blocking header hook — a form POST's result, a
|
||||
// service-worker-generated response, a click that Firefox routed straight to its own
|
||||
// downloader. Those surface here. If one looks like something the daemon should own,
|
||||
// offer it FIRST and only then cancel + erase Firefox's copy — so a failed or slow offer
|
||||
// can never leave the user with no download at all.
|
||||
//
|
||||
// blob:/data: downloads are left to Firefox: their bytes exist only in the page, and the
|
||||
// daemon cannot fetch a blob URL.
|
||||
|
||||
import type { CaptureOfferParams, CaptureOfferResult, CaptureRules, Cookie } from '../../shared/protocol/index.js';
|
||||
|
||||
import { guessFilename, toWireCookies, type CookieRecord } from './index.js';
|
||||
import { filenameExtension, hostExcluded } from './rules.js';
|
||||
|
||||
export interface DownloadItem {
|
||||
id: number;
|
||||
url: string;
|
||||
finalUrl?: string;
|
||||
referrer?: string;
|
||||
filename?: string;
|
||||
mime?: string;
|
||||
/** -1 when unknown. */
|
||||
fileSize?: number;
|
||||
/** -1 when unknown. */
|
||||
totalBytes?: number;
|
||||
state?: 'in_progress' | 'interrupted' | 'complete';
|
||||
}
|
||||
|
||||
interface DownloadEvent<L extends (...a: never[]) => void> {
|
||||
addListener(listener: L): void;
|
||||
removeListener(listener: L): void;
|
||||
}
|
||||
|
||||
export interface DownloadsApiLike {
|
||||
onCreated: DownloadEvent<(item: DownloadItem) => void>;
|
||||
cancel(id: number): Promise<void>;
|
||||
erase(query: { id: number }): Promise<number[]>;
|
||||
}
|
||||
|
||||
export interface DownloadsSafetyNetDeps {
|
||||
offer: (params: CaptureOfferParams, opts?: { timeoutMs?: number }) => Promise<CaptureOfferResult>;
|
||||
downloads: DownloadsApiLike;
|
||||
getCookies: (url: string) => Promise<CookieRecord[]>;
|
||||
getRules: () => CaptureRules;
|
||||
/** True when the header hook already offered this URL. */
|
||||
wasOffered?: (url: string) => boolean;
|
||||
onOffered?: (url: string) => void;
|
||||
origin?: string;
|
||||
budgetMs?: number;
|
||||
}
|
||||
|
||||
/** Why the safety net did or did not act on a created download — for the popup diagnostics. */
|
||||
export type SafetyNetOutcome =
|
||||
| 'taken' // offered and the daemon took it; Firefox's copy was cancelled + erased
|
||||
| 'offer_declined' // offered, daemon replied ignore
|
||||
| 'error' // fail-open: something threw, Firefox keeps the download
|
||||
| 'capture_disabled'
|
||||
| 'blob_or_data'
|
||||
| 'excluded_host'
|
||||
| 'already_offered'
|
||||
| 'already_settled'
|
||||
| 'no_rule_matched';
|
||||
|
||||
type VetoOutcome = Exclude<SafetyNetOutcome, 'taken' | 'offer_declined' | 'error'>;
|
||||
|
||||
const BUDGET_MS = 750;
|
||||
|
||||
export class DownloadsSafetyNet {
|
||||
private readonly budgetMs: number;
|
||||
private bound?: (item: DownloadItem) => void;
|
||||
private attachedTo?: DownloadsApiLike;
|
||||
|
||||
constructor(private readonly deps: DownloadsSafetyNetDeps) {
|
||||
this.budgetMs = deps.budgetMs ?? BUDGET_MS;
|
||||
}
|
||||
|
||||
attach(downloads: DownloadsApiLike): void {
|
||||
if (this.attachedTo) throw new Error('DownloadsSafetyNet is already attached');
|
||||
this.attachedTo = downloads;
|
||||
this.bound = (item) => void this.handle(item);
|
||||
downloads.onCreated.addListener(this.bound);
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
if (this.attachedTo && this.bound) this.attachedTo.onCreated.removeListener(this.bound);
|
||||
this.attachedTo = undefined;
|
||||
this.bound = undefined;
|
||||
}
|
||||
|
||||
/** Never throws. Returns what it did, for diagnostics/tests. */
|
||||
async handle(item: DownloadItem): Promise<SafetyNetOutcome> {
|
||||
try {
|
||||
const url = effectiveUrl(item);
|
||||
const decision = this.decide(item, url);
|
||||
if (!decision.capture) return decision.reason;
|
||||
|
||||
let cookies: Cookie[] = [];
|
||||
try {
|
||||
cookies = toWireCookies(await this.deps.getCookies(url));
|
||||
} catch {
|
||||
cookies = [];
|
||||
}
|
||||
|
||||
const params: CaptureOfferParams = {
|
||||
url,
|
||||
method: 'GET', // onCreated does not expose the method, and a POST body can't be replayed
|
||||
tabUrl: item.referrer ?? '',
|
||||
cookies,
|
||||
contentType: item.mime ?? null,
|
||||
contentLength: sizeOf(item),
|
||||
filename: basenameOf(item.filename) ?? guessFilename(url, undefined),
|
||||
referrer: item.referrer ?? null,
|
||||
origin: this.deps.origin ?? null,
|
||||
};
|
||||
|
||||
this.deps.onOffered?.(url);
|
||||
const result = await this.deps.offer(params, { timeoutMs: this.budgetMs });
|
||||
if (result.action !== 'take') return 'offer_declined';
|
||||
|
||||
// The daemon owns it now — retire Firefox's copy. Both best-effort.
|
||||
try {
|
||||
await this.deps.downloads.cancel(item.id);
|
||||
} catch {
|
||||
/* already complete or gone */
|
||||
}
|
||||
try {
|
||||
await this.deps.downloads.erase({ id: item.id });
|
||||
} catch {
|
||||
/* nothing to erase */
|
||||
}
|
||||
return 'taken';
|
||||
} catch {
|
||||
return 'error'; // fail open: Firefox keeps the download
|
||||
}
|
||||
}
|
||||
|
||||
private decide(item: DownloadItem, url: string): { capture: true } | { capture: false; reason: VetoOutcome } {
|
||||
const veto = (reason: VetoOutcome) => ({ capture: false as const, reason });
|
||||
const rules = this.deps.getRules();
|
||||
if (!rules.enabled) return veto('capture_disabled');
|
||||
if (/^(blob:|data:)/i.test(url)) return veto('blob_or_data');
|
||||
if (item.state === 'complete' || item.state === 'interrupted') return veto('already_settled');
|
||||
if (this.deps.wasOffered?.(url)) return veto('already_offered');
|
||||
|
||||
const host = hostOf(url);
|
||||
if (host && hostExcluded(host, rules.excludedHosts)) return veto('excluded_host');
|
||||
|
||||
const ext = filenameExtension(basenameOf(item.filename) ?? url);
|
||||
if (ext && rules.monitoredExtensions.includes(ext)) return { capture: true };
|
||||
if (item.mime && rules.monitoredMimeTypes.includes(item.mime.toLowerCase())) return { capture: true };
|
||||
|
||||
const size = sizeOf(item);
|
||||
if (size !== null && size > rules.minSizeBytes) return { capture: true };
|
||||
|
||||
return veto('no_rule_matched');
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ----------------------------------------------------------------------
|
||||
|
||||
function effectiveUrl(item: DownloadItem): string {
|
||||
return item.finalUrl && item.finalUrl.length > 0 ? item.finalUrl : item.url;
|
||||
}
|
||||
|
||||
function sizeOf(item: DownloadItem): number | null {
|
||||
for (const v of [item.totalBytes, item.fileSize]) {
|
||||
if (typeof v === 'number' && v > 0) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function basenameOf(filename: string | undefined): string | undefined {
|
||||
if (!filename) return undefined;
|
||||
const base = filename.split(/[\\/]/).pop();
|
||||
return base && base.length > 0 ? base : undefined;
|
||||
}
|
||||
|
||||
function hostOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,9 @@ export interface CaptureHookDeps {
|
||||
getRules: () => CaptureRules;
|
||||
/** Whether the user held the bypass modifier for the click behind this request. */
|
||||
bypassHeld?: (details: OnHeadersReceivedDetails) => boolean;
|
||||
/** Called with the URL just before an offer is made, so the downloads.onCreated safety
|
||||
* net can tell this request has already been handled here. */
|
||||
onOffered?: (url: string) => void;
|
||||
/** moz-extension://<uuid>, for CaptureOfferParams.origin. */
|
||||
origin?: string;
|
||||
budgetMs?: number;
|
||||
@@ -167,6 +170,7 @@ export class CaptureHook {
|
||||
requestId: details.requestId,
|
||||
};
|
||||
|
||||
this.deps.onOffered?.(details.url);
|
||||
const result = await this.deps.offer(params, { timeoutMs: this.budgetMs });
|
||||
return result.action === 'take' ? CANCEL : PROCEED;
|
||||
} catch {
|
||||
@@ -185,7 +189,7 @@ export class CaptureHook {
|
||||
|
||||
// --- helpers ----------------------------------------------------------------------
|
||||
|
||||
function toWireCookies(records: CookieRecord[]): Cookie[] {
|
||||
export function toWireCookies(records: CookieRecord[]): Cookie[] {
|
||||
return records.map((c) => {
|
||||
const out: Cookie = { name: c.name, value: c.value };
|
||||
if (c.domain !== undefined) out.domain = c.domain;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// A short-lived record of URLs the header hook has already offered to the daemon, so the
|
||||
// downloads.onCreated safety net does not offer the same thing a second time.
|
||||
//
|
||||
// Bounded and time-capped for the same reason the header stash is (capture/headers.ts):
|
||||
// this sees a URL for every capture attempt and must never grow without bound.
|
||||
|
||||
export interface OfferedUrlsOptions {
|
||||
/** Entries older than this stop counting as "recently offered". Default 15 s. */
|
||||
ttlMs?: number;
|
||||
/** Hard cap on tracked URLs; oldest evicted past it. Default 256. */
|
||||
maxEntries?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export class OfferedUrls {
|
||||
private readonly ttlMs: number;
|
||||
private readonly maxEntries: number;
|
||||
private readonly now: () => number;
|
||||
/** url -> timestamp, insertion-ordered so the first key is the oldest. */
|
||||
private readonly seen = new Map<string, number>();
|
||||
|
||||
constructor(opts: OfferedUrlsOptions = {}) {
|
||||
this.ttlMs = opts.ttlMs ?? 15_000;
|
||||
this.maxEntries = opts.maxEntries ?? 256;
|
||||
this.now = opts.now ?? Date.now;
|
||||
}
|
||||
|
||||
mark(url: string): void {
|
||||
this.seen.delete(url);
|
||||
this.seen.set(url, this.now());
|
||||
while (this.seen.size > this.maxEntries) {
|
||||
const oldest = this.seen.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
this.seen.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
has(url: string): boolean {
|
||||
const at = this.seen.get(url);
|
||||
if (at === undefined) return false;
|
||||
if (this.now() - at > this.ttlMs) {
|
||||
this.seen.delete(url);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.seen.size;
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,53 @@
|
||||
// Background event-page entry.
|
||||
//
|
||||
// Brings the transport up, mirrors the daemon's capture rules, and wires the two capture
|
||||
// paths: the blocking onHeadersReceived hook and (next) the downloads.onCreated safety
|
||||
// net. The popup relay and context menus attach here in later steps of the build order.
|
||||
// Brings the transport up, mirrors the daemon's capture rules, and wires both capture
|
||||
// paths: the blocking onHeadersReceived hook and the downloads.onCreated safety net. The
|
||||
// popup relay and context menus attach here in later steps of the build order.
|
||||
|
||||
import { CaptureHook } from './capture/index.js';
|
||||
import { HeaderStash } from './capture/headers.js';
|
||||
import type { WebRequestLike } from './capture/headers.js';
|
||||
import type { HeadersReceivedWebRequest } from './capture/index.js';
|
||||
import { DownloadsSafetyNet, type DownloadsApiLike } from './capture/downloads-api.js';
|
||||
import { HeaderStash, type WebRequestLike } from './capture/headers.js';
|
||||
import { CaptureHook, type HeadersReceivedWebRequest } from './capture/index.js';
|
||||
import { OfferedUrls } from './capture/offered-urls.js';
|
||||
import { DEFAULT_CAPTURE_RULES } from './capture/rules.js';
|
||||
import { createTransport, type TransportStatus, type VeloxTransport } from './transport/index.js';
|
||||
import type { CaptureRules } from '../shared/protocol/index.js';
|
||||
import type { CaptureOfferParams, CaptureRules } from '../shared/protocol/index.js';
|
||||
|
||||
let transport: VeloxTransport | undefined;
|
||||
let rules: CaptureRules = DEFAULT_CAPTURE_RULES;
|
||||
|
||||
const stash = new HeaderStash();
|
||||
const hook = new CaptureHook({
|
||||
offer: (params, opts) => mustTransport().call('capture.offer', params, opts),
|
||||
stash,
|
||||
getCookies: (url) => browser.cookies.getAll({ url }),
|
||||
getRules: () => rules,
|
||||
origin: new URL(browser.runtime.getURL('/')).origin,
|
||||
});
|
||||
const EXTENSION_ORIGIN = new URL(browser.runtime.getURL('/')).origin;
|
||||
|
||||
function mustTransport(): VeloxTransport {
|
||||
if (!transport) throw new Error('transport not ready');
|
||||
return transport;
|
||||
}
|
||||
|
||||
const offer = (params: CaptureOfferParams, opts?: { timeoutMs?: number }) =>
|
||||
mustTransport().call('capture.offer', params, opts);
|
||||
const getCookies = (url: string) => browser.cookies.getAll({ url });
|
||||
|
||||
const offered = new OfferedUrls();
|
||||
const stash = new HeaderStash();
|
||||
|
||||
const hook = new CaptureHook({
|
||||
offer,
|
||||
stash,
|
||||
getCookies,
|
||||
getRules: () => rules,
|
||||
onOffered: (url) => offered.mark(url),
|
||||
origin: EXTENSION_ORIGIN,
|
||||
});
|
||||
|
||||
const safetyNet = new DownloadsSafetyNet({
|
||||
offer,
|
||||
downloads: browser.downloads as unknown as DownloadsApiLike,
|
||||
getCookies,
|
||||
getRules: () => rules,
|
||||
wasOffered: (url) => offered.has(url),
|
||||
onOffered: (url) => offered.mark(url),
|
||||
origin: EXTENSION_ORIGIN,
|
||||
});
|
||||
|
||||
async function refreshRules(): Promise<void> {
|
||||
try {
|
||||
rules = await mustTransport().call('capture.getRules', {});
|
||||
@@ -46,6 +65,7 @@ function onTransportState(status: TransportStatus): void {
|
||||
async function start(): Promise<void> {
|
||||
stash.attach(browser.webRequest as unknown as WebRequestLike);
|
||||
hook.attach(browser.webRequest as unknown as HeadersReceivedWebRequest);
|
||||
safetyNet.attach(browser.downloads as unknown as DownloadsApiLike);
|
||||
|
||||
transport = await createTransport();
|
||||
transport.onStateChange(onTransportState);
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { OfferedUrls } from '../../src/background/capture/offered-urls.js';
|
||||
|
||||
describe('OfferedUrls', () => {
|
||||
it('remembers a URL for the TTL, then forgets it', () => {
|
||||
let now = 0;
|
||||
const o = new OfferedUrls({ ttlMs: 1000, now: () => now });
|
||||
o.mark('https://x/a.zip');
|
||||
expect(o.has('https://x/a.zip')).toBe(true);
|
||||
now = 1001;
|
||||
expect(o.has('https://x/a.zip')).toBe(false);
|
||||
expect(o.size).toBe(0);
|
||||
});
|
||||
|
||||
it('re-mark refreshes the TTL', () => {
|
||||
let now = 0;
|
||||
const o = new OfferedUrls({ ttlMs: 1000, now: () => now });
|
||||
o.mark('u');
|
||||
now = 900;
|
||||
o.mark('u');
|
||||
now = 1800; // 900 since the last mark
|
||||
expect(o.has('u')).toBe(true);
|
||||
});
|
||||
|
||||
it('evicts the oldest past the size cap', () => {
|
||||
const o = new OfferedUrls({ maxEntries: 2, ttlMs: 10_000, now: () => 0 });
|
||||
o.mark('a');
|
||||
o.mark('b');
|
||||
o.mark('c');
|
||||
expect(o.has('a')).toBe(false);
|
||||
expect(o.has('b')).toBe(true);
|
||||
expect(o.has('c')).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a URL it has never seen', () => {
|
||||
expect(new OfferedUrls().has('nope')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user