Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/app/api/prompts/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}) {
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\\)\\.%"
);
});
Comment on lines +53 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test validates JS-layer output, not actual PostgREST behaviour

The test verifies that or() is called with a specific escaped string, but because Supabase is fully mocked it cannot detect whether PostgREST would accept or correctly interpret that string. A scenario where backslash-escaped commas silently produce wrong query results (or a 400 from PostgREST) would pass this test. An integration test or a test asserting the double-quoting format recommended by PostgREST docs would give much stronger coverage.

});
16 changes: 15 additions & 1 deletion src/app/api/prompts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "\\.");
}
Comment on lines +8 to +17

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Backslash-escaping PostgREST reserved characters is not the supported mechanism

PostgREST's documented approach for handling reserved characters (,, ., :, ()) in filter values is to wrap the entire value in double quotes — not backslash-escape each character. The PostgREST URL grammar docs are explicit: "If filters include PostgREST reserved characters(,, ., :, ()) you'll have to surround them in percent encoded double quotes." Backslash-escaping is documented only for escaping " within a double-quoted value.

A search for AI, ML would produce title.ilike.%AI\, ML%,... — PostgREST's parser still treats the unquoted \, as a condition separator, which will either produce a parse error or an incorrectly constructed query. The correct approach is to double-quote the pattern and escape only " and \ within that quoted value, then use title.ilike."${safeSearch}" in the .or() string.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


/**
* GET /api/prompts - Public listing of active prompts
*/
Expand All @@ -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) {
Expand Down
Loading