From 3e4e35c9ae3e4de99966d99a62b6b8c2d527d8cc Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Wed, 29 Apr 2026 00:32:23 -0400 Subject: [PATCH] fix: tighten route integer parsing and test coverage --- packages/api-server/src/routes/ebay.ts | 105 +++---- packages/api-server/src/routes/helpers.ts | 17 +- packages/api-server/test/routes.test.ts | 316 +++++++++++++++++++++- 3 files changed, 377 insertions(+), 61 deletions(-) diff --git a/packages/api-server/src/routes/ebay.ts b/packages/api-server/src/routes/ebay.ts index 1fc6a6c..bad0225 100644 --- a/packages/api-server/src/routes/ebay.ts +++ b/packages/api-server/src/routes/ebay.ts @@ -11,59 +11,60 @@ import { * Search eBay for listings (default: Buy It Now only, Canada only) */ export async function ebayRoute(req: Request): Promise { + const reqUrl = new URL(req.url); + + const SEARCH_QUERY = getRequiredSearchQuery(req); + if (SEARCH_QUERY instanceof Response) { + return SEARCH_QUERY; + } + + const minPrice = parseNonNegativeIntegerParam( + reqUrl.searchParams, + "minPrice", + ); + if (minPrice instanceof Response) { + return minPrice; + } + const maxPrice = parseNonNegativeIntegerParam( + reqUrl.searchParams, + "maxPrice", + ); + if (maxPrice instanceof Response) { + return maxPrice; + } + const strictMode = reqUrl.searchParams.get("strictMode") === "true"; + const buyItNowOnly = reqUrl.searchParams.get("buyItNowOnly") !== "false"; + const canadaOnly = reqUrl.searchParams.get("canadaOnly") !== "false"; + const exclusionsParam = reqUrl.searchParams.get("exclusions"); + const exclusions = exclusionsParam + ? exclusionsParam.split(",").map((s) => s.trim()) + : []; + const keywordsParam = reqUrl.searchParams.get("keywords"); + const keywords = keywordsParam + ? keywordsParam.split(",").map((s) => s.trim()) + : [SEARCH_QUERY]; + + const maxItems = parseNonNegativeIntegerParam( + reqUrl.searchParams, + "maxItems", + ); + if (maxItems instanceof Response) { + return maxItems; + } + const hideUnstableResults = + reqUrl.searchParams.get("unstableFilter") === "true"; + const opts = { + minPrice, + maxPrice, + strictMode, + exclusions, + keywords, + buyItNowOnly, + canadaOnly, + maxItems, + }; + try { - const reqUrl = new URL(req.url); - - const SEARCH_QUERY = getRequiredSearchQuery(req); - if (SEARCH_QUERY instanceof Response) { - return SEARCH_QUERY; - } - - const minPrice = parseNonNegativeIntegerParam( - reqUrl.searchParams, - "minPrice", - ); - if (minPrice instanceof Response) { - return minPrice; - } - const maxPrice = parseNonNegativeIntegerParam( - reqUrl.searchParams, - "maxPrice", - ); - if (maxPrice instanceof Response) { - return maxPrice; - } - const strictMode = reqUrl.searchParams.get("strictMode") === "true"; - const buyItNowOnly = reqUrl.searchParams.get("buyItNowOnly") !== "false"; - const canadaOnly = reqUrl.searchParams.get("canadaOnly") !== "false"; - const exclusionsParam = reqUrl.searchParams.get("exclusions"); - const exclusions = exclusionsParam - ? exclusionsParam.split(",").map((s) => s.trim()) - : []; - const keywordsParam = reqUrl.searchParams.get("keywords"); - const keywords = keywordsParam - ? keywordsParam.split(",").map((s) => s.trim()) - : [SEARCH_QUERY]; - - const maxItems = parseNonNegativeIntegerParam( - reqUrl.searchParams, - "maxItems", - ); - if (maxItems instanceof Response) { - return maxItems; - } - const hideUnstableResults = - reqUrl.searchParams.get("unstableFilter") === "true"; - const opts = { - minPrice, - maxPrice, - strictMode, - exclusions, - keywords, - buyItNowOnly, - canadaOnly, - maxItems, - }; if (hideUnstableResults) { const items = await fetchEbayItems(SEARCH_QUERY, 1, opts, { hideUnstableResults: true, diff --git a/packages/api-server/src/routes/helpers.ts b/packages/api-server/src/routes/helpers.ts index 3b13092..0455bc9 100644 --- a/packages/api-server/src/routes/helpers.ts +++ b/packages/api-server/src/routes/helpers.ts @@ -12,6 +12,15 @@ export function getRequiredSearchQuery(req: Request): string | Response { return query; } +export function parseNonNegativeIntegerParam( + searchParams: URLSearchParams, + name: string, + defaultValue: number, +): number | Response; +export function parseNonNegativeIntegerParam( + searchParams: URLSearchParams, + name: string, +): number | undefined | Response; export function parseNonNegativeIntegerParam( searchParams: URLSearchParams, name: string, @@ -21,8 +30,14 @@ export function parseNonNegativeIntegerParam( if (rawValue === null) { return defaultValue; } + if (!/^\d+$/.test(rawValue)) { + return Response.json( + { message: `Invalid ${name} parameter` }, + { status: 400 }, + ); + } const value = Number(rawValue); - if (!Number.isInteger(value) || value < 0) { + if (value < 0) { return Response.json( { message: `Invalid ${name} parameter` }, { status: 400 }, diff --git a/packages/api-server/test/routes.test.ts b/packages/api-server/test/routes.test.ts index 64909ad..9be15ae 100644 --- a/packages/api-server/test/routes.test.ts +++ b/packages/api-server/test/routes.test.ts @@ -501,6 +501,66 @@ describe("API routes", () => { expect(body.message).toBe("Invalid maxItems parameter"); }); + test("ebayRoute returns 400 for non-integer maxItems", async () => { + const { ebayRoute } = await import("../src/routes/ebay"); + + const response = await ebayRoute( + new Request("http://localhost/api/ebay?q=laptop&maxItems=10abc"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid maxItems parameter"); + }); + + test("ebayRoute returns 400 for decimal maxItems", async () => { + const { ebayRoute } = await import("../src/routes/ebay"); + + const response = await ebayRoute( + new Request("http://localhost/api/ebay?q=laptop&maxItems=1.5"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid maxItems parameter"); + }); + + test("ebayRoute returns 400 for empty maxItems", async () => { + const { ebayRoute } = await import("../src/routes/ebay"); + + const response = await ebayRoute( + new Request("http://localhost/api/ebay?q=laptop&maxItems="), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid maxItems parameter"); + }); + + test("ebayRoute returns 400 for whitespace maxItems", async () => { + const { ebayRoute } = await import("../src/routes/ebay"); + + const response = await ebayRoute( + new Request("http://localhost/api/ebay?q=laptop&maxItems=%20%20"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid maxItems parameter"); + }); + + test("ebayRoute returns 400 for hex maxItems", async () => { + const { ebayRoute } = await import("../src/routes/ebay"); + + const response = await ebayRoute( + new Request("http://localhost/api/ebay?q=laptop&maxItems=0x10"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid maxItems parameter"); + }); + test("facebookRoute returns 400 for invalid maxItems", async () => { const { facebookRoute } = await import("../src/routes/facebook"); @@ -513,7 +573,67 @@ describe("API routes", () => { expect(body.message).toBe("Invalid maxItems parameter"); }); - test("ebayRoute rejects non-integer minPrice", async () => { + test("facebookRoute returns 400 for non-integer maxItems", async () => { + const { facebookRoute } = await import("../src/routes/facebook"); + + const response = await facebookRoute( + new Request("http://localhost/api/facebook?q=laptop&maxItems=10abc"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid maxItems parameter"); + }); + + test("facebookRoute returns 400 for decimal maxItems", async () => { + const { facebookRoute } = await import("../src/routes/facebook"); + + const response = await facebookRoute( + new Request("http://localhost/api/facebook?q=laptop&maxItems=1.5"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid maxItems parameter"); + }); + + test("facebookRoute returns 400 for empty maxItems", async () => { + const { facebookRoute } = await import("../src/routes/facebook"); + + const response = await facebookRoute( + new Request("http://localhost/api/facebook?q=laptop&maxItems="), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid maxItems parameter"); + }); + + test("facebookRoute returns 400 for whitespace maxItems", async () => { + const { facebookRoute } = await import("../src/routes/facebook"); + + const response = await facebookRoute( + new Request("http://localhost/api/facebook?q=laptop&maxItems=%20%20"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid maxItems parameter"); + }); + + test("facebookRoute returns 400 for hex maxItems", async () => { + const { facebookRoute } = await import("../src/routes/facebook"); + + const response = await facebookRoute( + new Request("http://localhost/api/facebook?q=laptop&maxItems=0x10"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid maxItems parameter"); + }); + + test("ebayRoute returns 400 for non-integer minPrice", async () => { const { ebayRoute } = await import("../src/routes/ebay"); const response = await ebayRoute( @@ -537,7 +657,19 @@ describe("API routes", () => { expect(body.message).toBe("Invalid minPrice parameter"); }); - test("ebayRoute rejects non-integer maxPrice", async () => { + test("ebayRoute returns 400 for decimal minPrice", async () => { + const { ebayRoute } = await import("../src/routes/ebay"); + + const response = await ebayRoute( + new Request("http://localhost/api/ebay?q=laptop&minPrice=1.5"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid minPrice parameter"); + }); + + test("ebayRoute returns 400 for non-integer maxPrice", async () => { const { ebayRoute } = await import("../src/routes/ebay"); const response = await ebayRoute( @@ -561,7 +693,19 @@ describe("API routes", () => { expect(body.message).toBe("Invalid maxPrice parameter"); }); - test("kijijiRoute rejects decimal maxPages", async () => { + test("ebayRoute returns 400 for decimal maxPrice", async () => { + const { ebayRoute } = await import("../src/routes/ebay"); + + const response = await ebayRoute( + new Request("http://localhost/api/ebay?q=laptop&maxPrice=1.5"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid maxPrice parameter"); + }); + + test("kijijiRoute returns 400 for decimal maxPages", async () => { const { kijijiRoute } = await import("../src/routes/kijiji"); const response = await kijijiRoute( @@ -585,6 +729,54 @@ describe("API routes", () => { expect(body.message).toBe("Invalid maxPages parameter"); }); + test("kijijiRoute returns 400 for non-integer maxPages", async () => { + const { kijijiRoute } = await import("../src/routes/kijiji"); + + const response = await kijijiRoute( + new Request("http://localhost/api/kijiji?q=laptop&maxPages=10abc"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid maxPages parameter"); + }); + + test("kijijiRoute returns 400 for empty maxPages", async () => { + const { kijijiRoute } = await import("../src/routes/kijiji"); + + const response = await kijijiRoute( + new Request("http://localhost/api/kijiji?q=laptop&maxPages="), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid maxPages parameter"); + }); + + test("kijijiRoute returns 400 for whitespace maxPages", async () => { + const { kijijiRoute } = await import("../src/routes/kijiji"); + + const response = await kijijiRoute( + new Request("http://localhost/api/kijiji?q=laptop&maxPages=%20%20"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid maxPages parameter"); + }); + + test("kijijiRoute returns 400 for hex maxPages", async () => { + const { kijijiRoute } = await import("../src/routes/kijiji"); + + const response = await kijijiRoute( + new Request("http://localhost/api/kijiji?q=laptop&maxPages=0x10"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid maxPages parameter"); + }); + test("kijijiRoute returns 400 for invalid priceMin", async () => { const { kijijiRoute } = await import("../src/routes/kijiji"); @@ -597,6 +789,66 @@ describe("API routes", () => { expect(body.message).toBe("Invalid priceMin parameter"); }); + test("kijijiRoute returns 400 for decimal priceMin", async () => { + const { kijijiRoute } = await import("../src/routes/kijiji"); + + const response = await kijijiRoute( + new Request("http://localhost/api/kijiji?q=laptop&priceMin=1.5"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid priceMin parameter"); + }); + + test("kijijiRoute returns 400 for non-integer priceMin", async () => { + const { kijijiRoute } = await import("../src/routes/kijiji"); + + const response = await kijijiRoute( + new Request("http://localhost/api/kijiji?q=laptop&priceMin=10abc"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid priceMin parameter"); + }); + + test("kijijiRoute returns 400 for empty priceMin", async () => { + const { kijijiRoute } = await import("../src/routes/kijiji"); + + const response = await kijijiRoute( + new Request("http://localhost/api/kijiji?q=laptop&priceMin="), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid priceMin parameter"); + }); + + test("kijijiRoute returns 400 for whitespace priceMin", async () => { + const { kijijiRoute } = await import("../src/routes/kijiji"); + + const response = await kijijiRoute( + new Request("http://localhost/api/kijiji?q=laptop&priceMin=%20%20"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid priceMin parameter"); + }); + + test("kijijiRoute returns 400 for hex priceMin", async () => { + const { kijijiRoute } = await import("../src/routes/kijiji"); + + const response = await kijijiRoute( + new Request("http://localhost/api/kijiji?q=laptop&priceMin=0x10"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid priceMin parameter"); + }); + test("kijijiRoute returns 400 for invalid priceMax", async () => { const { kijijiRoute } = await import("../src/routes/kijiji"); @@ -609,16 +861,64 @@ describe("API routes", () => { expect(body.message).toBe("Invalid priceMax parameter"); }); - test("facebookRoute rejects non-integer maxItems", async () => { - const { facebookRoute } = await import("../src/routes/facebook"); + test("kijijiRoute returns 400 for decimal priceMax", async () => { + const { kijijiRoute } = await import("../src/routes/kijiji"); - const response = await facebookRoute( - new Request("http://localhost/api/facebook?q=laptop&maxItems=10abc"), + const response = await kijijiRoute( + new Request("http://localhost/api/kijiji?q=laptop&priceMax=1.5"), ); expect(response.status).toBe(400); const body = await response.json(); - expect(body.message).toBe("Invalid maxItems parameter"); + expect(body.message).toBe("Invalid priceMax parameter"); + }); + + test("kijijiRoute returns 400 for non-integer priceMax", async () => { + const { kijijiRoute } = await import("../src/routes/kijiji"); + + const response = await kijijiRoute( + new Request("http://localhost/api/kijiji?q=laptop&priceMax=10abc"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid priceMax parameter"); + }); + + test("kijijiRoute returns 400 for empty priceMax", async () => { + const { kijijiRoute } = await import("../src/routes/kijiji"); + + const response = await kijijiRoute( + new Request("http://localhost/api/kijiji?q=laptop&priceMax="), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid priceMax parameter"); + }); + + test("kijijiRoute returns 400 for whitespace priceMax", async () => { + const { kijijiRoute } = await import("../src/routes/kijiji"); + + const response = await kijijiRoute( + new Request("http://localhost/api/kijiji?q=laptop&priceMax=%20%20"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid priceMax parameter"); + }); + + test("kijijiRoute returns 400 for hex priceMax", async () => { + const { kijijiRoute } = await import("../src/routes/kijiji"); + + const response = await kijijiRoute( + new Request("http://localhost/api/kijiji?q=laptop&priceMax=0x10"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid priceMax parameter"); }); test("facebookRoute returns 400 for negative maxItems", async () => {