From dff40b5e24bd26cb103fae53e7746456bf38d996 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 27 Mar 2026 14:49:00 -0400 Subject: [PATCH] fix(websearch): align provider validation metadata --- src/web-server/routes/websearch-routes.ts | 8 +- .../unit/web-server/websearch-routes.test.ts | 73 +++++++++++++------ 2 files changed, 58 insertions(+), 23 deletions(-) diff --git a/src/web-server/routes/websearch-routes.ts b/src/web-server/routes/websearch-routes.ts index 7bfae45f..fc550868 100644 --- a/src/web-server/routes/websearch-routes.ts +++ b/src/web-server/routes/websearch-routes.ts @@ -9,6 +9,7 @@ import { getWebSearchReadiness, getWebSearchCliProviders } from '../../utils/web import { applyWebSearchApiKeyUpdates, getWebSearchApiKeyStates, + WEBSEARCH_API_KEY_PROVIDERS, type WebSearchApiKeyProviderId, } from '../../utils/websearch/provider-secrets'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; @@ -16,7 +17,6 @@ import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middlewar const router = Router(); const WEBSEARCH_LOCAL_ACCESS_ERROR = 'WebSearch endpoints require localhost access when dashboard auth is disabled.'; -const WEBSEARCH_API_KEY_PROVIDER_IDS = ['exa', 'tavily', 'brave'] as const; type WebSearchApiKeyUpdates = Partial>; @@ -24,6 +24,10 @@ interface WebSearchDashboardPayload extends Partial { apiKeys?: WebSearchApiKeyUpdates; } +function isWebSearchApiKeyProviderId(value: string): value is WebSearchApiKeyProviderId { + return Object.prototype.hasOwnProperty.call(WEBSEARCH_API_KEY_PROVIDERS, value); +} + router.use((req: Request, res: Response, next) => { if (requireLocalAccessWhenAuthDisabled(req, res, WEBSEARCH_LOCAL_ACCESS_ERROR)) { next(); @@ -88,7 +92,7 @@ router.put('/', (req: Request, res: Response): void => { if (apiKeys) { for (const [providerId, value] of Object.entries(apiKeys)) { - if (!WEBSEARCH_API_KEY_PROVIDER_IDS.includes(providerId as WebSearchApiKeyProviderId)) { + if (!isWebSearchApiKeyProviderId(providerId)) { res.status(400).json({ error: `Unsupported WebSearch provider: ${providerId}` }); return; } diff --git a/tests/unit/web-server/websearch-routes.test.ts b/tests/unit/web-server/websearch-routes.test.ts index 42f9b08b..dd4c5abe 100644 --- a/tests/unit/web-server/websearch-routes.test.ts +++ b/tests/unit/web-server/websearch-routes.test.ts @@ -28,6 +28,17 @@ describe('websearch routes', () => { let originalEnvValues: Record<(typeof WEBSEARCH_ENV_KEYS)[number], string | undefined>; let forcedRemoteAddress = '127.0.0.1'; + async function putWebsearch( + body: string | Record, + headers: Record = { 'Content-Type': 'application/json' } + ): Promise { + return fetch(`${baseUrl}/api/websearch`, { + method: 'PUT', + headers, + body: typeof body === 'string' ? body : JSON.stringify(body), + }); + } + beforeAll(async () => { const app = express(); app.use(express.json()); @@ -278,11 +289,7 @@ describe('websearch routes', () => { }); it('rejects non-object request bodies', async () => { - const response = await fetch(`${baseUrl}/api/websearch`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: '[]', - }); + const response = await putWebsearch('[]'); expect(response.status).toBe(400); expect(await response.json()).toEqual({ @@ -290,15 +297,16 @@ describe('websearch routes', () => { }); }); + it('rejects primitive JSON null bodies before route validation', async () => { + const response = await putWebsearch('null'); + expect(response.status).toBe(400); + }); + it('rejects unsupported API key providers', async () => { - const response = await fetch(`${baseUrl}/api/websearch`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - apiKeys: { - invalid: 'secret', - }, - }), + const response = await putWebsearch({ + apiKeys: { + invalid: 'secret', + }, }); expect(response.status).toBe(400); @@ -308,14 +316,10 @@ describe('websearch routes', () => { }); it('rejects non-string API key values', async () => { - const response = await fetch(`${baseUrl}/api/websearch`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - apiKeys: { - exa: 123, - }, - }), + const response = await putWebsearch({ + apiKeys: { + exa: 123, + }, }); expect(response.status).toBe(400); @@ -323,4 +327,31 @@ describe('websearch routes', () => { error: 'Invalid value for exa API key', }); }); + + it('rejects null and array values for providers and apiKeys', async () => { + const invalidPayloads = [ + { + body: { providers: null }, + error: 'Invalid value for providers. Must be an object.', + }, + { + body: { providers: [] }, + error: 'Invalid value for providers. Must be an object.', + }, + { + body: { apiKeys: null }, + error: 'Invalid value for apiKeys. Must be an object.', + }, + { + body: { apiKeys: [] }, + error: 'Invalid value for apiKeys. Must be an object.', + }, + ] as const; + + for (const invalidPayload of invalidPayloads) { + const response = await putWebsearch(invalidPayload.body); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: invalidPayload.error }); + } + }); });