diff --git a/src/app/api/prompts/route.test.ts b/src/app/api/prompts/route.test.ts new file mode 100644 index 00000000..c84a5e1c --- /dev/null +++ b/src/app/api/prompts/route.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mockFrom = vi.fn(); + +const supabaseClient = { + from: mockFrom, +}; + +vi.mock("@/lib/supabase/server", () => ({ + createClient: vi.fn(() => Promise.resolve(supabaseClient)), +})); + +vi.mock("@/lib/auth/get-user", () => ({ + getAuthContext: vi.fn(), +})); + +vi.mock("@/lib/supabase/service", () => ({ + createServiceClient: vi.fn(() => supabaseClient), +})); + +vi.mock("@/lib/prompts/security-scan", () => ({ + scanPrompt: vi.fn(), +})); + +import { GET } from "./route"; + +function makeGetRequest(params: Record = {}) { + const url = new URL("http://localhost/api/prompts"); + Object.entries(params).forEach(([key, value]) => { + url.searchParams.set(key, value); + }); + return new NextRequest(url, { method: "GET" }); +} + +function makePromptQuery() { + const range = vi.fn().mockResolvedValue({ data: [], count: 0, error: null }); + const order = vi.fn().mockReturnValue({ range }); + const overlaps = vi.fn().mockReturnValue({ order }); + const eqAfterSearch = vi.fn().mockReturnValue({ order }); + const or = vi.fn().mockReturnValue({ eq: eqAfterSearch, overlaps, order }); + const eq = vi.fn().mockReturnValue({ or, eq: eqAfterSearch, overlaps, order }); + const select = vi.fn().mockReturnValue({ eq }); + + return { select, eq, or, eqAfterSearch, overlaps, order, range }; +} + +describe("GET /api/prompts", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("escapes PostgREST filter characters in search terms", async () => { + const query = makePromptQuery(); + mockFrom.mockReturnValue(query); + + const response = await GET(makeGetRequest({ search: "ai%,foo_(v1)." })); + + expect(response.status).toBe(200); + expect(query.or).toHaveBeenCalledWith( + "title.ilike.%ai\\%\\,foo\\_\\(v1\\)\\.%,description.ilike.%ai\\%\\,foo\\_\\(v1\\)\\.%,tagline.ilike.%ai\\%\\,foo\\_\\(v1\\)\\.%" + ); + }); +}); diff --git a/src/app/api/prompts/route.ts b/src/app/api/prompts/route.ts index bee67939..37db441f 100644 --- a/src/app/api/prompts/route.ts +++ b/src/app/api/prompts/route.ts @@ -5,6 +5,17 @@ import { createServiceClient } from "@/lib/supabase/service"; import { promptListingSchema, slugify } from "@/lib/prompts/validation"; import { scanPrompt } from "@/lib/prompts/security-scan"; +function escapePostgrestSearch(value: string) { + return value + .replace(/\\/g, "\\\\") + .replace(/%/g, "\\%") + .replace(/_/g, "\\_") + .replace(/,/g, "\\,") + .replace(/\(/g, "\\(") + .replace(/\)/g, "\\)") + .replace(/\./g, "\\."); +} + /** * GET /api/prompts - Public listing of active prompts */ @@ -31,7 +42,10 @@ export async function GET(request: NextRequest) { .eq("status", "active"); if (search) { - query = query.or(`title.ilike.%${search}%,description.ilike.%${search}%,tagline.ilike.%${search}%`); + const safeSearch = escapePostgrestSearch(search); + query = query.or( + `title.ilike.%${safeSearch}%,description.ilike.%${safeSearch}%,tagline.ilike.%${safeSearch}%` + ); } if (category) {