Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
709 lines
27 KiB
TypeScript
709 lines
27 KiB
TypeScript
import http from 'node:http';
|
|
import type { AddressInfo } from 'node:net';
|
|
import { getLogLevel, setLogLevel, subscribeLogs, type LogEntry } from '@snowluma/common/logger';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { RequestUtil } from '@snowluma/protocol/web/request-util';
|
|
|
|
const previousLogLevel = getLogLevel();
|
|
|
|
const servers = new Set<http.Server>();
|
|
|
|
async function serve(handler: http.RequestListener): Promise<{ server: http.Server; url: string }> {
|
|
const server = http.createServer(handler);
|
|
servers.add(server);
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', resolve);
|
|
});
|
|
const address = server.address() as AddressInfo;
|
|
return { server, url: `http://127.0.0.1:${address.port}` };
|
|
}
|
|
|
|
afterEach(async () => {
|
|
setLogLevel(previousLogLevel);
|
|
vi.useRealTimers();
|
|
await Promise.all(
|
|
[...servers].map(async (server) => {
|
|
server.closeAllConnections();
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
servers.delete(server);
|
|
}),
|
|
);
|
|
});
|
|
|
|
function httpTrace(entries: LogEntry[]): LogEntry[] {
|
|
return entries.filter((entry) => entry.level === 'trace' && entry.scope === 'Protocol.Http');
|
|
}
|
|
|
|
describe('RequestUtil TRACE lifecycle', () => {
|
|
it('records complete request and parsed response data under one request context', async () => {
|
|
const { url } = await serve(async (req, res) => {
|
|
const chunks: Buffer[] = [];
|
|
|
|
for await (const chunk of req) {
|
|
chunks.push(Buffer.from(chunk));
|
|
}
|
|
|
|
expect(Buffer.concat(chunks).toString()).toBe('{"message":"request-secret"}');
|
|
res.setHeader('X-Response-Token', 'response-secret-header');
|
|
res.end('{"ok":true,"token":"response-secret-body"}');
|
|
});
|
|
const entries: LogEntry[] = [];
|
|
setLogLevel('trace');
|
|
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
|
|
|
try {
|
|
await expect(
|
|
RequestUtil.HttpGetJson(
|
|
`${url}/write?access_token=url-secret`,
|
|
'POST',
|
|
{ message: 'request-secret' },
|
|
{
|
|
Authorization: 'Bearer request-secret-header',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
),
|
|
).resolves.toEqual({ ok: true, token: 'response-secret-body' });
|
|
|
|
const trace = httpTrace(entries);
|
|
expect(trace.length).toBeGreaterThanOrEqual(4);
|
|
expect(
|
|
trace.every((entry) => entry.req !== undefined && entry.req === trace[0]!.req),
|
|
).toBe(true);
|
|
expect(trace.map((entry) => entry.message)).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining('http_start method="POST"'),
|
|
expect.stringContaining('access_token=url-secret'),
|
|
expect.stringContaining('request-secret-header'),
|
|
expect.stringContaining('request-secret'),
|
|
expect.stringContaining('http_response status=200'),
|
|
expect.stringContaining('response-secret-header'),
|
|
expect.stringContaining('response-secret-body'),
|
|
expect.stringContaining('http_branch branch=parse_completed'),
|
|
expect.stringMatching(
|
|
/^http_terminal method="POST" .* outcome=completed reason=response_complete elapsedMs=\d+$/,
|
|
),
|
|
]),
|
|
);
|
|
expect(
|
|
trace.filter((entry) => entry.message.startsWith('http_terminal ')),
|
|
).toHaveLength(1);
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
});
|
|
|
|
it('records exact request and response bytes for binary HTTP data', async () => {
|
|
const requestBytes = Uint8Array.from([0x00, 0xff, 0x80, 0x41]);
|
|
const responseBytes = Buffer.from([0xde, 0xad, 0xbe, 0xef, 0x00]);
|
|
const { url } = await serve(async (req, res) => {
|
|
const chunks: Buffer[] = [];
|
|
|
|
for await (const chunk of req) {
|
|
chunks.push(Buffer.from(chunk));
|
|
}
|
|
|
|
expect(Buffer.concat(chunks)).toEqual(Buffer.from(requestBytes));
|
|
res.end(responseBytes);
|
|
});
|
|
const entries: LogEntry[] = [];
|
|
setLogLevel('trace');
|
|
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
|
|
|
try {
|
|
await expect(
|
|
RequestUtil.HttpGetText(url, 'POST', requestBytes, {
|
|
'Content-Type': 'application/octet-stream',
|
|
}),
|
|
).resolves.toBe(responseBytes.toString());
|
|
|
|
const messages = httpTrace(entries).map((entry) => entry.message);
|
|
expect(messages).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining('requestBytes=4 requestHex=00ff8041'),
|
|
expect.stringContaining('bodyBytes=5 bodyHex=deadbeef00'),
|
|
]),
|
|
);
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
});
|
|
|
|
it('records redirect input changes and one non-2xx terminal without waiting for the body', async () => {
|
|
const target = await serve((_req, res) => {
|
|
res.writeHead(403, { 'X-Diagnostic': 'denied' });
|
|
res.flushHeaders();
|
|
res.write('body that never ends');
|
|
});
|
|
const source = await serve((_req, res) => {
|
|
res.writeHead(302, { location: target.url });
|
|
res.end();
|
|
});
|
|
const entries: LogEntry[] = [];
|
|
setLogLevel('trace');
|
|
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
|
|
|
try {
|
|
await expect(
|
|
RequestUtil.HttpGetText(source.url, 'GET', undefined, {
|
|
Cookie: 'uin=redirect-secret',
|
|
}),
|
|
).rejects.toThrow('Unexpected status code: 403');
|
|
|
|
const messages = httpTrace(entries).map((entry) => entry.message);
|
|
expect(messages).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining('http_branch branch=redirect status=302'),
|
|
expect.stringContaining(`to=${JSON.stringify(`${target.url}/`)}`),
|
|
expect.stringContaining('http_response status=403'),
|
|
expect.stringContaining('bodyState=not_read'),
|
|
expect.stringMatching(
|
|
/^http_terminal method="GET" .* outcome=failed reason=non_2xx .*elapsedMs=\d+$/,
|
|
),
|
|
]),
|
|
);
|
|
expect(messages.filter((message) => message.startsWith('http_terminal '))).toHaveLength(
|
|
1,
|
|
);
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
});
|
|
|
|
it.each(['not a valid URL', 'file:///tmp/not-a-web-request'])(
|
|
'classifies invalid initial request URL %j without falling back to transport or redirect failure',
|
|
async (url) => {
|
|
const entries: LogEntry[] = [];
|
|
setLogLevel('trace');
|
|
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
|
|
|
try {
|
|
await expect(RequestUtil.HttpGetText(url)).rejects.toThrow();
|
|
const terminals = httpTrace(entries).filter((entry) =>
|
|
entry.message.startsWith('http_terminal '),
|
|
);
|
|
expect(terminals).toHaveLength(1);
|
|
expect(terminals[0]!.message).toContain('outcome=failed reason=request_invalid');
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
['timeoutMs', { timeoutMs: 0 }],
|
|
['maxResponseBytes', { maxResponseBytes: 0 }],
|
|
] as const)('traces invalid %s before rejecting the request', async (_name, limits) => {
|
|
const entries: LogEntry[] = [];
|
|
setLogLevel('trace');
|
|
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
|
|
|
try {
|
|
await expect(
|
|
RequestUtil.HttpGetText('http://127.0.0.1/', 'GET', undefined, {}, limits),
|
|
).rejects.toThrow('must be a positive safe integer');
|
|
const trace = httpTrace(entries);
|
|
expect(trace.some((entry) => entry.message.startsWith('http_start '))).toBe(true);
|
|
const terminals = trace.filter((entry) => entry.message.startsWith('http_terminal '));
|
|
expect(terminals).toHaveLength(1);
|
|
expect(terminals[0]!.message).toContain('outcome=failed reason=request_invalid');
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: 'method token',
|
|
run: () => RequestUtil.HttpGetText('http://127.0.0.1:1/', 'BAD METHOD'),
|
|
},
|
|
{
|
|
name: 'header value',
|
|
run: () =>
|
|
RequestUtil.HttpGetText('http://127.0.0.1:1/', 'GET', undefined, {
|
|
'X-Invalid': 'line one\nline two',
|
|
}),
|
|
},
|
|
{
|
|
name: 'circular JSON body',
|
|
run: () => {
|
|
const body: Record<string, unknown> = {};
|
|
body.self = body;
|
|
return RequestUtil.HttpGetJson('http://127.0.0.1:1/', 'POST', body);
|
|
},
|
|
},
|
|
])('classifies an invalid local $name as request_invalid', async ({ run }) => {
|
|
const entries: LogEntry[] = [];
|
|
setLogLevel('trace');
|
|
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
|
|
|
try {
|
|
await expect(run()).rejects.toThrow();
|
|
const terminals = httpTrace(entries).filter((entry) =>
|
|
entry.message.startsWith('http_terminal '),
|
|
);
|
|
expect(terminals).toHaveLength(1);
|
|
expect(terminals[0]!.message).toContain('outcome=failed reason=request_invalid');
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: 'parse failure',
|
|
run: async () => {
|
|
const { url } = await serve((_req, res) => res.end('{invalid-json'));
|
|
return RequestUtil.HttpGetJson(url);
|
|
},
|
|
reason: 'parse_failure',
|
|
outcome: 'failed',
|
|
bodyState: undefined,
|
|
},
|
|
{
|
|
name: 'timeout',
|
|
run: async () => {
|
|
const { url } = await serve((_req, res) => {
|
|
res.writeHead(200);
|
|
res.write('partial');
|
|
});
|
|
return RequestUtil.HttpGetText(url, 'GET', undefined, {}, { timeoutMs: 30 });
|
|
},
|
|
reason: 'deadline',
|
|
outcome: 'timeout',
|
|
bodyState: 'deadline',
|
|
},
|
|
{
|
|
name: 'transport failure',
|
|
run: async () => {
|
|
const { url } = await serve((req) => req.socket.destroy());
|
|
return RequestUtil.HttpGetText(url);
|
|
},
|
|
reason: 'transport_failure',
|
|
outcome: 'failed',
|
|
bodyState: undefined,
|
|
},
|
|
{
|
|
name: 'response cancellation',
|
|
run: async () => {
|
|
const { url } = await serve((_req, res) => {
|
|
res.writeHead(200, { 'Content-Length': '100' });
|
|
res.flushHeaders();
|
|
res.write('partial');
|
|
setImmediate(() => res.destroy());
|
|
});
|
|
return RequestUtil.HttpGetText(url, 'GET', undefined, {}, { timeoutMs: 250 });
|
|
},
|
|
reason: 'response_aborted',
|
|
outcome: 'cancelled',
|
|
bodyState: 'aborted',
|
|
},
|
|
])('records a unique $name terminal', async ({ run, reason, outcome, bodyState }) => {
|
|
const entries: LogEntry[] = [];
|
|
setLogLevel('trace');
|
|
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
|
|
|
try {
|
|
await expect(run()).rejects.toThrow();
|
|
const terminals = httpTrace(entries).filter((entry) =>
|
|
entry.message.startsWith('http_terminal '),
|
|
);
|
|
expect(terminals).toHaveLength(1);
|
|
expect(terminals[0]!.message).toContain(`outcome=${outcome} reason=${reason}`);
|
|
|
|
if (bodyState !== undefined) {
|
|
expect(httpTrace(entries).map((entry) => entry.message)).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining(
|
|
`bodyState=${bodyState} bodyBytes=7 bodyHex=7061727469616c body="partial"`,
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('RequestUtil cookie redirect TRACE lifecycle', () => {
|
|
it('records collected cookies and a completed terminal across redirects', async () => {
|
|
const { url } = await serve((req, res) => {
|
|
if (req.url === '/start') {
|
|
res.writeHead(302, {
|
|
location: '/finish',
|
|
'Set-Cookie': 'p_skey=first-secret; Path=/',
|
|
});
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
res.writeHead(200, { 'Set-Cookie': 'skey=second-secret; Path=/' });
|
|
res.end('ok');
|
|
});
|
|
const entries: LogEntry[] = [];
|
|
setLogLevel('trace');
|
|
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
|
|
|
try {
|
|
await expect(RequestUtil.HttpsGetCookies(`${url}/start`)).resolves.toEqual({
|
|
p_skey: 'first-secret',
|
|
skey: 'second-secret',
|
|
});
|
|
const trace = httpTrace(entries);
|
|
expect(
|
|
trace.every((entry) => entry.req !== undefined && entry.req === trace[0]!.req),
|
|
).toBe(true);
|
|
expect(trace.map((entry) => entry.message)).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining('http_cookie_start'),
|
|
expect.stringContaining('branch=redirect'),
|
|
expect.stringContaining('first-secret'),
|
|
expect.stringContaining('second-secret'),
|
|
expect.stringContaining('bodyBytes=2 bodyHex=6f6b body="ok"'),
|
|
expect.stringMatching(
|
|
/^http_cookie_terminal .* outcome=completed reason=response_complete .*elapsedMs=\d+$/,
|
|
),
|
|
]),
|
|
);
|
|
expect(
|
|
trace.filter((entry) => entry.message.startsWith('http_cookie_terminal ')),
|
|
).toHaveLength(1);
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
});
|
|
|
|
it('fails open when a cookie response aborts after headers', async () => {
|
|
const { url } = await serve((_req, res) => {
|
|
res.writeHead(200, {
|
|
'Content-Length': '100',
|
|
'Set-Cookie': 'p_skey=partial-secret; Path=/',
|
|
});
|
|
res.flushHeaders();
|
|
res.write('partial');
|
|
setImmediate(() => res.destroy());
|
|
});
|
|
const entries: LogEntry[] = [];
|
|
setLogLevel('trace');
|
|
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
|
|
|
try {
|
|
await expect(RequestUtil.HttpsGetCookies(url)).resolves.toEqual({
|
|
p_skey: 'partial-secret',
|
|
});
|
|
const terminals = httpTrace(entries).filter((entry) =>
|
|
entry.message.startsWith('http_cookie_terminal '),
|
|
);
|
|
expect(terminals).toHaveLength(1);
|
|
expect(terminals[0]!.message).toContain(
|
|
'outcome=completed reason=transport_failure failOpen=true',
|
|
);
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
});
|
|
|
|
it('traces partial cookie response bytes before a fail-open deadline terminal', async () => {
|
|
const { url } = await serve((_req, res) => {
|
|
res.writeHead(200, {
|
|
'Content-Length': '100',
|
|
'Set-Cookie': 'p_skey=partial-secret; Path=/',
|
|
});
|
|
res.flushHeaders();
|
|
res.write('partial');
|
|
});
|
|
const entries: LogEntry[] = [];
|
|
setLogLevel('trace');
|
|
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
|
|
|
try {
|
|
await expect(RequestUtil.HttpsGetCookies(url)).resolves.toEqual({
|
|
p_skey: 'partial-secret',
|
|
});
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
|
|
const messages = httpTrace(entries).map((entry) => entry.message);
|
|
const bodyIndex = messages.findIndex(
|
|
(message) =>
|
|
message.includes('http_cookie_body ') &&
|
|
message.includes('bodyBytes=7 bodyHex=7061727469616c body="partial"'),
|
|
);
|
|
const terminalIndex = messages.findIndex(
|
|
(message) =>
|
|
message.includes('http_cookie_terminal ') &&
|
|
message.includes('reason=deadline failOpen=true'),
|
|
);
|
|
expect(bodyIndex).toBeGreaterThanOrEqual(0);
|
|
expect(terminalIndex).toBeGreaterThan(bodyIndex);
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
}, 10_000);
|
|
|
|
it('records a fail-open transport terminal while returning cookies collected earlier', async () => {
|
|
const { url } = await serve((req, res) => {
|
|
if (req.url === '/start') {
|
|
res.writeHead(302, {
|
|
location: '/broken',
|
|
'Set-Cookie': 'p_skey=collected-secret; Path=/',
|
|
});
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
req.socket.destroy(new Error('fixture cookie transport reset'));
|
|
});
|
|
const entries: LogEntry[] = [];
|
|
setLogLevel('trace');
|
|
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
|
|
|
try {
|
|
await expect(RequestUtil.HttpsGetCookies(`${url}/start`)).resolves.toEqual({
|
|
p_skey: 'collected-secret',
|
|
});
|
|
const terminals = httpTrace(entries).filter((entry) =>
|
|
entry.message.startsWith('http_cookie_terminal '),
|
|
);
|
|
expect(terminals).toHaveLength(1);
|
|
expect(terminals[0]!.message).toContain(
|
|
'outcome=completed reason=transport_failure failOpen=true',
|
|
);
|
|
expect(terminals[0]!.message).toContain('collected-secret');
|
|
expect(terminals[0]!.message).toContain('socket hang up');
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('RequestUtil bounded text responses', () => {
|
|
it('rejects a response once it exceeds the byte limit and traces every observed byte', async () => {
|
|
const entries: LogEntry[] = [];
|
|
const body = 'x'.repeat(65);
|
|
const { url } = await serve((_req, res) => {
|
|
res.end(body);
|
|
});
|
|
setLogLevel('trace');
|
|
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
|
|
|
try {
|
|
await expect(
|
|
RequestUtil.HttpGetText(url, 'GET', undefined, {}, { maxResponseBytes: 64 }),
|
|
).rejects.toThrow('response body exceeds 64 bytes');
|
|
|
|
expect(httpTrace(entries).map((entry) => entry.message)).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining(
|
|
`bodyState=too_large bodyBytes=65 bodyHex=${'78'.repeat(65)}`,
|
|
),
|
|
]),
|
|
);
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
});
|
|
|
|
it('rejects a request that does not complete before its deadline', async () => {
|
|
const { url } = await serve((_req, res) => {
|
|
res.writeHead(200);
|
|
res.write('partial');
|
|
});
|
|
|
|
await expect(
|
|
RequestUtil.HttpGetText(url, 'GET', undefined, {}, { timeoutMs: 30 }),
|
|
).rejects.toThrow('request timed out after 30 ms');
|
|
});
|
|
|
|
it('measures the response limit in bytes and accepts the exact boundary', async () => {
|
|
const exact = '界'.repeat(16);
|
|
const { url } = await serve((_req, res) => {
|
|
res.end(exact);
|
|
});
|
|
|
|
await expect(
|
|
RequestUtil.HttpGetText(
|
|
url,
|
|
'GET',
|
|
undefined,
|
|
{},
|
|
{ maxResponseBytes: Buffer.byteLength(exact) },
|
|
),
|
|
).resolves.toBe(exact);
|
|
});
|
|
|
|
it('rejects a multibyte response that exceeds the byte boundary', async () => {
|
|
const tooLarge = '界'.repeat(17);
|
|
const { url } = await serve((_req, res) => {
|
|
res.end(tooLarge);
|
|
});
|
|
|
|
await expect(
|
|
RequestUtil.HttpGetText(url, 'GET', undefined, {}, { maxResponseBytes: 48 }),
|
|
).rejects.toThrow('response body exceeds 48 bytes');
|
|
});
|
|
|
|
it('closes a non-success response without waiting for its body to end', async () => {
|
|
let markClosed!: () => void;
|
|
const closed = new Promise<void>((resolve) => {
|
|
markClosed = resolve;
|
|
});
|
|
const { url } = await serve((_req, res) => {
|
|
res.once('close', markClosed);
|
|
res.writeHead(403);
|
|
res.flushHeaders();
|
|
res.write('body that never ends');
|
|
});
|
|
|
|
await expect(RequestUtil.HttpGetText(url)).rejects.toThrow('Unexpected status code: 403');
|
|
await expect(
|
|
Promise.race([
|
|
closed.then(() => true),
|
|
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 250)),
|
|
]),
|
|
).resolves.toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('RequestUtil redirects', () => {
|
|
it('strips credentials when a redirect crosses origins', async () => {
|
|
let redirectedHeaders: http.IncomingHttpHeaders | undefined;
|
|
const target = await serve((req, res) => {
|
|
redirectedHeaders = req.headers;
|
|
res.end('ok');
|
|
});
|
|
const source = await serve((_req, res) => {
|
|
res.writeHead(302, { location: target.url });
|
|
res.end();
|
|
});
|
|
|
|
await expect(
|
|
RequestUtil.HttpGetText(source.url, 'GET', undefined, {
|
|
Cookie: 'uin=secret',
|
|
Authorization: 'Bearer secret',
|
|
'Proxy-Authorization': 'Basic secret',
|
|
'X-SnowLuma-Test': 'kept',
|
|
}),
|
|
).resolves.toBe('ok');
|
|
|
|
expect(redirectedHeaders?.cookie).toBeUndefined();
|
|
expect(redirectedHeaders?.authorization).toBeUndefined();
|
|
expect(redirectedHeaders?.['proxy-authorization']).toBeUndefined();
|
|
expect(redirectedHeaders?.['x-snowluma-test']).toBe('kept');
|
|
});
|
|
|
|
it('never forwards a write request body across origins', async () => {
|
|
let targetReached = false;
|
|
const target = await serve((_req, res) => {
|
|
targetReached = true;
|
|
res.end('unexpected');
|
|
});
|
|
const source = await serve((_req, res) => {
|
|
res.writeHead(307, { location: target.url });
|
|
res.end();
|
|
});
|
|
|
|
await expect(
|
|
RequestUtil.HttpGetText(source.url, 'POST', 'secret-body', {
|
|
'Content-Type': 'text/plain',
|
|
}),
|
|
).rejects.toThrow('cross-origin redirect cannot forward method POST');
|
|
expect(targetReached).toBe(false);
|
|
});
|
|
|
|
it('preserves credentials for same-origin redirects', async () => {
|
|
let redirectedCookie: string | undefined;
|
|
const { url } = await serve((req, res) => {
|
|
if (req.url === '/start') {
|
|
res.writeHead(302, { location: '/finish' });
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
redirectedCookie = req.headers.cookie;
|
|
res.end('ok');
|
|
});
|
|
|
|
await expect(
|
|
RequestUtil.HttpGetText(`${url}/start`, 'GET', undefined, {
|
|
Cookie: 'uin=same-origin',
|
|
}),
|
|
).resolves.toBe('ok');
|
|
expect(redirectedCookie).toBe('uin=same-origin');
|
|
});
|
|
|
|
it('inherits response limits after a redirect', async () => {
|
|
const target = await serve((_req, res) => {
|
|
res.end('x'.repeat(65));
|
|
});
|
|
const source = await serve((_req, res) => {
|
|
res.writeHead(302, { location: target.url });
|
|
res.end();
|
|
});
|
|
|
|
await expect(
|
|
RequestUtil.HttpGetText(source.url, 'GET', undefined, {}, { maxResponseBytes: 64 }),
|
|
).rejects.toThrow('response body exceeds 64 bytes');
|
|
});
|
|
|
|
it('shares one wall-clock deadline across the redirect chain', async () => {
|
|
const target = await serve((_req, res) => {
|
|
setTimeout(() => res.end('late'), 80);
|
|
});
|
|
const source = await serve((_req, res) => {
|
|
setTimeout(() => {
|
|
res.writeHead(302, { location: target.url });
|
|
res.end();
|
|
}, 80);
|
|
});
|
|
|
|
await expect(
|
|
RequestUtil.HttpGetText(source.url, 'GET', undefined, {}, { timeoutMs: 100 }),
|
|
).rejects.toThrow('request timed out after 100 ms');
|
|
});
|
|
|
|
it('rejects malformed redirect locations instead of throwing outside the Promise', async () => {
|
|
const { url } = await serve((_req, res) => {
|
|
res.writeHead(302, { location: 'http://[' });
|
|
res.end();
|
|
});
|
|
|
|
await expect(RequestUtil.HttpGetText(url)).rejects.toThrow('invalid redirect location');
|
|
});
|
|
|
|
it('rejects redirects to non-HTTP protocols', async () => {
|
|
const { url } = await serve((_req, res) => {
|
|
res.writeHead(302, { location: 'file:///tmp/not-a-web-response' });
|
|
res.end();
|
|
});
|
|
|
|
await expect(RequestUtil.HttpGetText(url)).rejects.toThrow(
|
|
'unsupported redirect protocol: file:',
|
|
);
|
|
});
|
|
|
|
it('closes the previous response before following a redirect', async () => {
|
|
const target = await serve((_req, res) => {
|
|
res.end('ok');
|
|
});
|
|
let markClosed!: () => void;
|
|
const closed = new Promise<void>((resolve) => {
|
|
markClosed = resolve;
|
|
});
|
|
const source = await serve((_req, res) => {
|
|
res.once('close', markClosed);
|
|
res.writeHead(302, { location: target.url });
|
|
res.flushHeaders();
|
|
res.write('body that never ends');
|
|
});
|
|
|
|
await expect(RequestUtil.HttpGetText(source.url)).resolves.toBe('ok');
|
|
await expect(
|
|
Promise.race([
|
|
closed.then(() => true),
|
|
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 250)),
|
|
]),
|
|
).resolves.toBe(true);
|
|
});
|
|
});
|