ext: capture/headers.ts — request-header stash
onHeadersReceived (where shouldCapture runs) doesn't carry the headers the
browser actually sent; signed-URL and referrer-gated CDNs need them. Stash
from onBeforeSendHeaders keyed by requestId, read back at capture time.
This map sees every request, so it is bounded both ways — oldest-out past a
size cap (default 2048) and a 5-minute TTL, swept on a 60 s timer and on read
— and attach() clears an entry the moment its request completes or errors.
normalizeHeaders(): Array<{name,value}> -> lower-cased map, repeats joined
with ", ". 13 tests: eviction order, redirect re-put refresh, TTL expiry,
sweep, and the onBeforeSendHeaders/onCompleted/onErrorOccurred wiring.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y9RU58hD1BuwP82DySUHk
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
HeaderStash,
|
||||
normalizeHeaders,
|
||||
type OnBeforeSendHeadersDetails,
|
||||
type WebRequestLike,
|
||||
} from '../../src/background/capture/headers.js';
|
||||
|
||||
function details(over: Partial<OnBeforeSendHeadersDetails> = {}): OnBeforeSendHeadersDetails {
|
||||
return {
|
||||
requestId: '1',
|
||||
url: 'https://cdn.example.com/big.zip',
|
||||
method: 'GET',
|
||||
tabId: 7,
|
||||
requestHeaders: [
|
||||
{ name: 'User-Agent', value: 'Firefox' },
|
||||
{ name: 'Referer', value: 'https://example.com/page' },
|
||||
{ name: 'Cookie', value: 'sid=abc' },
|
||||
],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('normalizeHeaders', () => {
|
||||
it('lower-cases names and drops valueless entries', () => {
|
||||
expect(
|
||||
normalizeHeaders([
|
||||
{ name: 'Accept-Encoding', value: 'gzip' },
|
||||
{ name: 'X-Empty' },
|
||||
]),
|
||||
).toEqual({ 'accept-encoding': 'gzip' });
|
||||
});
|
||||
|
||||
it('joins repeated header names with ", "', () => {
|
||||
expect(
|
||||
normalizeHeaders([
|
||||
{ name: 'X-Fwd', value: 'a' },
|
||||
{ name: 'x-fwd', value: 'b' },
|
||||
]),
|
||||
).toEqual({ 'x-fwd': 'a, b' });
|
||||
});
|
||||
|
||||
it('returns {} for missing headers', () => {
|
||||
expect(normalizeHeaders(undefined)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeaderStash', () => {
|
||||
let now = 1_000_000;
|
||||
const clock = () => now;
|
||||
|
||||
afterEach(() => {
|
||||
now = 1_000_000;
|
||||
});
|
||||
|
||||
it('stashes and reads back normalized headers', () => {
|
||||
const s = new HeaderStash({ now: clock, sweepIntervalMs: 0 });
|
||||
s.put(details());
|
||||
const got = s.peek('1');
|
||||
expect(got).toMatchObject({
|
||||
requestId: '1',
|
||||
method: 'GET',
|
||||
tabId: 7,
|
||||
headers: { 'user-agent': 'Firefox', referer: 'https://example.com/page', cookie: 'sid=abc' },
|
||||
});
|
||||
});
|
||||
|
||||
it('take() reads once then the entry is gone', () => {
|
||||
const s = new HeaderStash({ now: clock, sweepIntervalMs: 0 });
|
||||
s.put(details());
|
||||
expect(s.take('1')).toBeDefined();
|
||||
expect(s.take('1')).toBeUndefined();
|
||||
expect(s.size).toBe(0);
|
||||
});
|
||||
|
||||
it('evicts the oldest entry past the size cap', () => {
|
||||
const s = new HeaderStash({ now: clock, maxEntries: 2, sweepIntervalMs: 0 });
|
||||
s.put(details({ requestId: 'a' }));
|
||||
s.put(details({ requestId: 'b' }));
|
||||
s.put(details({ requestId: 'c' }));
|
||||
expect(s.size).toBe(2);
|
||||
expect(s.peek('a')).toBeUndefined();
|
||||
expect(s.peek('b')).toBeDefined();
|
||||
expect(s.peek('c')).toBeDefined();
|
||||
});
|
||||
|
||||
it('re-putting a requestId refreshes its recency and TTL (redirect case)', () => {
|
||||
const s = new HeaderStash({ now: clock, maxEntries: 2, ttlMs: 1000, sweepIntervalMs: 0 });
|
||||
s.put(details({ requestId: 'a' }));
|
||||
now += 10;
|
||||
s.put(details({ requestId: 'b' }));
|
||||
now += 10;
|
||||
s.put(details({ requestId: 'a', url: 'https://cdn.example.com/redirected.zip' })); // a moves to newest
|
||||
s.put(details({ requestId: 'c' })); // evicts the now-oldest, which is b
|
||||
expect(s.peek('b')).toBeUndefined();
|
||||
expect(s.peek('a')?.url).toBe('https://cdn.example.com/redirected.zip');
|
||||
now += 995; // < 1000 since the re-put of a
|
||||
expect(s.peek('a')).toBeDefined();
|
||||
});
|
||||
|
||||
it('treats an entry past its TTL as absent and deletes it on read', () => {
|
||||
const s = new HeaderStash({ now: clock, ttlMs: 5 * 60_000, sweepIntervalMs: 0 });
|
||||
s.put(details());
|
||||
now += 5 * 60_000 + 1;
|
||||
expect(s.peek('1')).toBeUndefined();
|
||||
expect(s.size).toBe(0);
|
||||
});
|
||||
|
||||
it('sweep() drops expired entries and keeps fresh ones', () => {
|
||||
const s = new HeaderStash({ now: clock, ttlMs: 1000, sweepIntervalMs: 0 });
|
||||
s.put(details({ requestId: 'old1' }));
|
||||
s.put(details({ requestId: 'old2' }));
|
||||
now += 1001;
|
||||
s.put(details({ requestId: 'fresh' }));
|
||||
expect(s.sweep()).toBe(2);
|
||||
expect(s.size).toBe(1);
|
||||
expect(s.peek('fresh')).toBeDefined();
|
||||
});
|
||||
|
||||
it('drop() forgets one request', () => {
|
||||
const s = new HeaderStash({ now: clock, sweepIntervalMs: 0 });
|
||||
s.put(details({ requestId: 'x' }));
|
||||
s.drop('x');
|
||||
expect(s.peek('x')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// --- attach() wiring ---------------------------------------------------------------
|
||||
|
||||
class FakeEvent<L extends (...a: never[]) => void> {
|
||||
listeners: L[] = [];
|
||||
addListener = (l: L): void => void this.listeners.push(l);
|
||||
removeListener = (l: L): void => {
|
||||
this.listeners = this.listeners.filter((x) => x !== l);
|
||||
};
|
||||
emit(...args: Parameters<L>): void {
|
||||
for (const l of this.listeners) (l as (...a: Parameters<L>) => void)(...args);
|
||||
}
|
||||
}
|
||||
|
||||
function fakeWebRequest() {
|
||||
return {
|
||||
onBeforeSendHeaders: new FakeEvent<(d: OnBeforeSendHeadersDetails) => void>(),
|
||||
onCompleted: new FakeEvent<(d: { requestId: string }) => void>(),
|
||||
onErrorOccurred: new FakeEvent<(d: { requestId: string }) => void>(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('HeaderStash.attach', () => {
|
||||
it('stashes on onBeforeSendHeaders and forgets on onCompleted / onErrorOccurred', () => {
|
||||
const wr = fakeWebRequest();
|
||||
const s = new HeaderStash({ sweepIntervalMs: 0 });
|
||||
s.attach(wr as unknown as WebRequestLike);
|
||||
|
||||
wr.onBeforeSendHeaders.emit(details({ requestId: 'done' }));
|
||||
wr.onBeforeSendHeaders.emit(details({ requestId: 'failed' }));
|
||||
expect(s.size).toBe(2);
|
||||
|
||||
wr.onCompleted.emit({ requestId: 'done' });
|
||||
wr.onErrorOccurred.emit({ requestId: 'failed' });
|
||||
expect(s.size).toBe(0);
|
||||
|
||||
s.detach();
|
||||
wr.onBeforeSendHeaders.emit(details({ requestId: 'after-detach' }));
|
||||
expect(s.size).toBe(0);
|
||||
expect(wr.onBeforeSendHeaders.listeners).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('runs a background sweep on its interval', () => {
|
||||
vi.useFakeTimers();
|
||||
let t = 0;
|
||||
const s = new HeaderStash({ now: () => t, ttlMs: 1000, sweepIntervalMs: 500 });
|
||||
const wr = fakeWebRequest();
|
||||
s.attach(wr as unknown as WebRequestLike);
|
||||
wr.onBeforeSendHeaders.emit(details({ requestId: 'a' }));
|
||||
|
||||
t = 2000;
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(s.size).toBe(0);
|
||||
|
||||
s.detach();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('refuses a double attach', () => {
|
||||
const s = new HeaderStash({ sweepIntervalMs: 0 });
|
||||
const wr = fakeWebRequest();
|
||||
s.attach(wr as unknown as WebRequestLike);
|
||||
expect(() => s.attach(wr as unknown as WebRequestLike)).toThrow(/already attached/);
|
||||
s.detach();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user