fix(web-search): harden client response boundary
This commit is contained in:
@@ -10,6 +10,7 @@ const RELEASE_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const ADMISSION_ID = 'admission-a';
|
||||
const PROJECT_ID = '33333333-3333-4333-8333-333333333333';
|
||||
const LOGICAL_OPERATION_ID = 'pi:run-a:resource-a';
|
||||
const MAX_JSON_BYTES = 1_048_576;
|
||||
|
||||
function search(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -126,7 +127,7 @@ describe('WebSearchClient', () => {
|
||||
});
|
||||
|
||||
it('matches the frozen server source title and URL bounds exactly', async () => {
|
||||
const maximumUrl = `https://example.test/${'x'.repeat(4_096 - 'https://example.test/'.length)}`;
|
||||
const maximumUrl = `https://example.test/${'x'.repeat(2_048 - 'https://example.test/'.length)}`;
|
||||
await expect(new WebSearchClient({
|
||||
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(search({
|
||||
sources: [{ title: 't'.repeat(240), url: maximumUrl }],
|
||||
@@ -146,6 +147,25 @@ describe('WebSearchClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'http://user:password@example.test/source',
|
||||
'javascript:alert(1)',
|
||||
'//example.test/source',
|
||||
'https:example.test/source',
|
||||
'https://example.test/' + 'x'.repeat(2_049 - 'https://example.test/'.length),
|
||||
])('rejects an unsafe or overlong source URL: %s', async (url) => {
|
||||
const client = new WebSearchClient({
|
||||
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(search({
|
||||
sources: [{ title: 'Source', url }],
|
||||
}))),
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
});
|
||||
|
||||
await expect(client.search(input())).rejects.toMatchObject<WebSearchClientError>({
|
||||
code: 'plugin_backend_invalid', status: 502, retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves only a bounded Retry-After for a known rate limit', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(
|
||||
{ detail: { error_code: 'web_search_rate_limited', message: 'try later' } },
|
||||
@@ -162,6 +182,38 @@ describe('WebSearchClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uses HTTP 429 as the authoritative non-retryable rate-limit result', async () => {
|
||||
const responses = [
|
||||
new Response('{malformed', {
|
||||
status: 429,
|
||||
headers: { 'retry-after': '31' },
|
||||
}),
|
||||
new Response('x'.repeat(MAX_JSON_BYTES + 1), {
|
||||
status: 429,
|
||||
headers: { 'retry-after': '31' },
|
||||
}),
|
||||
jsonResponse(
|
||||
{ detail: { error_code: 'plugin_provider_unavailable', message: 'private' } },
|
||||
429,
|
||||
{ 'retry-after': '31' },
|
||||
),
|
||||
];
|
||||
|
||||
for (const response of responses) {
|
||||
const client = new WebSearchClient({
|
||||
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(response),
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
});
|
||||
|
||||
await expect(client.search(input())).rejects.toMatchObject<WebSearchClientError>({
|
||||
code: 'web_search_rate_limited',
|
||||
status: 429,
|
||||
retryable: false,
|
||||
retryAfterSeconds: 31,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('projects a server-side rate-limit result and does not trust Retry-After on other statuses', async () => {
|
||||
const rateLimited = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(search({
|
||||
status: 'failed',
|
||||
@@ -207,6 +259,101 @@ describe('WebSearchClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('bounds a chunked response before buffering the complete body', async () => {
|
||||
let cancelled = false;
|
||||
let chunks = 0;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
controller.enqueue(new Uint8Array(chunks++ === 0 ? MAX_JSON_BYTES : 1));
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
const response = new Response(stream, { status: 200 });
|
||||
const arrayBuffer = vi.spyOn(response, 'arrayBuffer');
|
||||
const client = new WebSearchClient({
|
||||
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(response),
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
});
|
||||
|
||||
await expect(client.search(input())).rejects.toMatchObject<WebSearchClientError>({
|
||||
code: 'plugin_backend_response_too_large', status: 502, retryable: false,
|
||||
});
|
||||
expect(arrayBuffer).not.toHaveBeenCalled();
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects incoherent search, billing, and error pairings', async () => {
|
||||
const invalidPayloads = [
|
||||
search({
|
||||
billing: {
|
||||
mode: 'platform_metered', status: 'settled', reserved_points: '1.00',
|
||||
actual_points: '1', usage_amount: 1, unit: 'search_request',
|
||||
},
|
||||
}),
|
||||
search({
|
||||
billing: {
|
||||
mode: 'platform_metered', status: 'settled', reserved_points: '1.00',
|
||||
actual_points: '1.000', usage_amount: 1, unit: 'search_request',
|
||||
},
|
||||
}),
|
||||
search({
|
||||
billing: {
|
||||
mode: 'platform_metered', status: 'settled', reserved_points: '1.00',
|
||||
actual_points: '1.00', usage_amount: 2, unit: 'search_request',
|
||||
},
|
||||
}),
|
||||
search({
|
||||
billing: {
|
||||
mode: 'platform_metered', status: 'settled', reserved_points: '1.00',
|
||||
actual_points: '1.00', unit: 'search_request',
|
||||
},
|
||||
}),
|
||||
search({ sources: [] }),
|
||||
search({
|
||||
status: 'pending_review',
|
||||
billing: {
|
||||
mode: 'platform_metered', status: 'pending_review', reserved_points: '1.00',
|
||||
usage_amount: 1, unit: 'search_request',
|
||||
},
|
||||
sources: [],
|
||||
}),
|
||||
search({
|
||||
billing: {
|
||||
mode: 'platform_metered', status: 'released', reserved_points: '1.00',
|
||||
unit: 'search_request',
|
||||
},
|
||||
}),
|
||||
search({
|
||||
status: 'failed', answer: null, sources: [], search_queries: [], error_code: null,
|
||||
billing: {
|
||||
mode: 'platform_metered', status: 'released', reserved_points: '1.00',
|
||||
usage_amount: 1, unit: 'search_request',
|
||||
},
|
||||
}),
|
||||
search({
|
||||
status: 'submission_unknown', answer: null, sources: [], search_queries: [],
|
||||
error_code: 'web_search_rate_limited',
|
||||
billing: {
|
||||
mode: 'platform_metered', status: 'pending_review', reserved_points: '1.00',
|
||||
usage_amount: 1, unit: 'search_request',
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
for (const payload of invalidPayloads) {
|
||||
const client = new WebSearchClient({
|
||||
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(payload)),
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
});
|
||||
|
||||
await expect(client.search(input())).rejects.toMatchObject<WebSearchClientError>({
|
||||
code: 'plugin_backend_invalid', status: 502, retryable: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts omitted nullable/default response fields while keeping the object closed', async () => {
|
||||
const payload = search();
|
||||
delete payload.answer;
|
||||
@@ -215,6 +362,7 @@ describe('WebSearchClient', () => {
|
||||
delete payload.error_code;
|
||||
delete payload.retry_after_seconds;
|
||||
payload.status = 'failed';
|
||||
payload.error_code = 'web_search_request_invalid';
|
||||
payload.billing = {
|
||||
mode: 'platform_metered', status: 'released', reserved_points: '1.00', unit: 'search_request',
|
||||
};
|
||||
@@ -222,7 +370,7 @@ describe('WebSearchClient', () => {
|
||||
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(payload)),
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
}).search(input())).resolves.toMatchObject({
|
||||
status: 'failed', answer: null, sources: [], searchQueries: [], errorCode: null,
|
||||
status: 'failed', answer: null, sources: [], searchQueries: [], errorCode: 'web_search_request_invalid',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user