diff --git a/src/app/api/playground/improve-prompt/route.ts b/src/app/api/playground/improve-prompt/route.ts new file mode 100644 index 0000000000..a0efd85d67 --- /dev/null +++ b/src/app/api/playground/improve-prompt/route.ts @@ -0,0 +1,151 @@ +/** + * POST /api/playground/improve-prompt + * + * Improves a user-supplied system prompt and/or user prompt by calling + * /v1/chat/completions internally with a meta-prompt (D8 — uses the model + * chosen by the user, never forces a cheap model). + * + * Request: ImprovePromptRequestSchema { system?, prompt?, model, tone? } + * Response: { improvedSystem?, improvedPrompt?, tokensIn, tokensOut } + * + * Auth: optional (mirrors /v1/web/fetch pattern — required only when + * REQUIRE_API_KEY=true; always validated when supplied). + * + * Hard Rule #12: ALL error paths route through buildErrorBody. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; +import { extractApiKey, isValidApiKey } from "@/sse/services/auth"; +import { + ImprovePromptRequestSchema, + buildImproveChatBody, + parseImprovedContent, +} from "@/lib/playground/promptImprover"; + +const CORS_HEADERS = { + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "*", +}; + +function errorResp(status: number, message: string, upstreamDetails?: unknown): Response { + return new Response( + JSON.stringify(buildErrorBody(status, sanitizeErrorMessage(message), upstreamDetails)), + { + status, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + } + ); +} + +export async function OPTIONS(): Promise { + return new Response(null, { headers: CORS_HEADERS }); +} + +export async function POST(request: Request): Promise { + // 1. Parse JSON body + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return errorResp(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body"); + } + + // 2. Validate via Zod (Hard Rule #7) + const parsed = ImprovePromptRequestSchema.safeParse(rawBody); + if (!parsed.success) { + const firstIssue = parsed.error.issues[0]; + const message = firstIssue + ? `${firstIssue.path.join(".") || "body"}: ${firstIssue.message}` + : "Invalid request body"; + return errorResp(HTTP_STATUS.BAD_REQUEST, message); + } + const body = parsed.data; + + // 3. Optional auth (mirrors /v1/web/fetch pattern) + const apiKeyRaw = extractApiKey(request); + if (process.env.REQUIRE_API_KEY === "true" && !apiKeyRaw) { + return errorResp(HTTP_STATUS.UNAUTHORIZED, "Authentication required"); + } + if (apiKeyRaw && !(await isValidApiKey(apiKeyRaw))) { + return errorResp(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); + } + + // 4. Build the meta-prompt chat body + const chatBody = buildImproveChatBody(body); + + // 5. Call /v1/chat/completions on ourselves (D8) + const port = process.env.PORT ?? "20128"; + const baseUrl = + process.env.OMNIROUTE_BASE_URL ?? `http://127.0.0.1:${port}`; + const upstreamUrl = `${baseUrl}/v1/chat/completions`; + + let upstreamResponse: Response; + try { + upstreamResponse = await fetch(upstreamUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKeyRaw ?? ""}`, + }, + body: JSON.stringify(chatBody), + }); + } catch (err: unknown) { + const safeMsg = sanitizeErrorMessage( + err instanceof Error ? err.message : "Failed to reach upstream" + ); + return errorResp(HTTP_STATUS.SERVER_ERROR, `Improve-prompt failed: ${safeMsg}`); + } + + if (!upstreamResponse.ok) { + let upstreamText = ""; + try { + upstreamText = await upstreamResponse.text(); + } catch { + // ignore + } + return errorResp( + upstreamResponse.status >= 400 && upstreamResponse.status < 600 + ? upstreamResponse.status + : HTTP_STATUS.BAD_GATEWAY, + "Improve-prompt upstream request failed", + sanitizeErrorMessage(upstreamText) + ); + } + + // 6. Parse response + let data: { + choices?: Array<{ message?: { content?: string | null } }>; + usage?: { prompt_tokens?: number; completion_tokens?: number }; + }; + try { + data = (await upstreamResponse.json()) as typeof data; + } catch { + return errorResp(HTTP_STATUS.BAD_GATEWAY, "Invalid JSON from upstream improve-prompt call"); + } + + const content = data.choices?.[0]?.message?.content; + if (typeof content !== "string") { + return errorResp(HTTP_STATUS.BAD_GATEWAY, "Empty or missing content from upstream"); + } + + // 7. Parse improved content back into system/prompt parts + const hadSystem = Boolean(body.system?.trim()); + const hadPrompt = Boolean(body.prompt?.trim()); + const improved = parseImprovedContent(content, hadSystem, hadPrompt); + + const tokensIn = data.usage?.prompt_tokens ?? 0; + const tokensOut = data.usage?.completion_tokens ?? 0; + + return new Response( + JSON.stringify({ + ...improved, + tokensIn, + tokensOut, + }), + { + status: 200, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + } + ); +} diff --git a/src/app/api/playground/presets/[id]/route.ts b/src/app/api/playground/presets/[id]/route.ts new file mode 100644 index 0000000000..ceabc40429 --- /dev/null +++ b/src/app/api/playground/presets/[id]/route.ts @@ -0,0 +1,189 @@ +/** + * GET /api/playground/presets/[id] — get a single preset + * PUT /api/playground/presets/[id] — update a preset (partial patch) + * DELETE /api/playground/presets/[id] — delete a preset (204 on success) + * + * Auth: optional (extractApiKey + isValidApiKey; required when REQUIRE_API_KEY=true). + * Hard Rule #5: all DB access via src/lib/db/playgroundPresets (never raw SQL). + * Hard Rule #7: PUT body validated via PlaygroundPresetUpdateSchema. + * Hard Rule #12: all error paths via buildErrorBody. + */ + +import { z } from "zod"; +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; +import { extractApiKey, isValidApiKey } from "@/sse/services/auth"; +import { + getPlaygroundPreset, + updatePlaygroundPreset, + deletePlaygroundPreset, +} from "@/lib/db/playgroundPresets"; +import { PlaygroundPresetUpdateSchema } from "@/shared/schemas/playground"; + +const CORS_HEADERS = { + "Access-Control-Allow-Methods": "GET, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "*", +}; + +function errorResp(status: number, message: string): Response { + return new Response( + JSON.stringify(buildErrorBody(status, sanitizeErrorMessage(message))), + { + status, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + } + ); +} + +async function checkAuth(request: Request): Promise { + const apiKeyRaw = extractApiKey(request); + if (process.env.REQUIRE_API_KEY === "true" && !apiKeyRaw) { + return errorResp(HTTP_STATUS.UNAUTHORIZED, "Authentication required"); + } + if (apiKeyRaw && !(await isValidApiKey(apiKeyRaw))) { + return errorResp(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); + } + return null; +} + +function validateId(id: unknown): { valid: true; id: string } | { valid: false; error: Response } { + const result = z.string().uuid().safeParse(id); + if (!result.success) { + return { + valid: false, + error: errorResp(HTTP_STATUS.BAD_REQUEST, "Invalid preset id: must be a valid UUID"), + }; + } + return { valid: true, id: result.data }; +} + +export async function OPTIONS(): Promise { + return new Response(null, { headers: CORS_HEADERS }); +} + +/** + * GET /api/playground/presets/[id] + * Returns the preset or 404. + */ +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +): Promise { + const authError = await checkAuth(request); + if (authError) return authError; + + const { id: rawId } = await params; + const validation = validateId(rawId); + if (!validation.valid) return validation.error; + const { id } = validation; + + try { + const preset = getPlaygroundPreset(id); + if (!preset) { + return errorResp(HTTP_STATUS.NOT_FOUND, `Preset not found: ${id}`); + } + return new Response(JSON.stringify(preset), { + status: 200, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + } catch (err: unknown) { + const safeMsg = sanitizeErrorMessage( + err instanceof Error ? err.message : "Failed to fetch preset" + ); + return errorResp(HTTP_STATUS.SERVER_ERROR, safeMsg); + } +} + +/** + * PUT /api/playground/presets/[id] + * Body: PlaygroundPresetUpdateSchema (partial) + * Returns the updated preset or 404. + */ +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +): Promise { + const authError = await checkAuth(request); + if (authError) return authError; + + const { id: rawId } = await params; + const validation = validateId(rawId); + if (!validation.valid) return validation.error; + const { id } = validation; + + // Parse JSON body + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return errorResp(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body"); + } + + // Validate body (Hard Rule #7) + const parsed = PlaygroundPresetUpdateSchema.safeParse(rawBody); + if (!parsed.success) { + const firstIssue = parsed.error.issues[0]; + const message = firstIssue + ? `${firstIssue.path.join(".") || "body"}: ${firstIssue.message}` + : "Invalid request body"; + return errorResp(HTTP_STATUS.BAD_REQUEST, message); + } + const patch = parsed.data; + + try { + const updated = updatePlaygroundPreset(id, { + ...(patch.name !== undefined ? { name: patch.name } : {}), + ...(patch.endpoint !== undefined ? { endpoint: patch.endpoint } : {}), + ...(patch.model !== undefined ? { model: patch.model } : {}), + ...("system" in patch ? { system: patch.system ?? null } : {}), + ...(patch.params !== undefined ? { params: patch.params } : {}), + }); + + if (!updated) { + return errorResp(HTTP_STATUS.NOT_FOUND, `Preset not found: ${id}`); + } + + return new Response(JSON.stringify(updated), { + status: 200, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + } catch (err: unknown) { + const safeMsg = sanitizeErrorMessage( + err instanceof Error ? err.message : "Failed to update preset" + ); + return errorResp(HTTP_STATUS.SERVER_ERROR, safeMsg); + } +} + +/** + * DELETE /api/playground/presets/[id] + * Returns 204 on success; 404 if not found. + */ +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +): Promise { + const authError = await checkAuth(request); + if (authError) return authError; + + const { id: rawId } = await params; + const validation = validateId(rawId); + if (!validation.valid) return validation.error; + const { id } = validation; + + try { + const deleted = deletePlaygroundPreset(id); + if (!deleted) { + return errorResp(HTTP_STATUS.NOT_FOUND, `Preset not found: ${id}`); + } + return new Response(null, { + status: 204, + headers: CORS_HEADERS, + }); + } catch (err: unknown) { + const safeMsg = sanitizeErrorMessage( + err instanceof Error ? err.message : "Failed to delete preset" + ); + return errorResp(HTTP_STATUS.SERVER_ERROR, safeMsg); + } +} diff --git a/src/app/api/playground/presets/route.ts b/src/app/api/playground/presets/route.ts new file mode 100644 index 0000000000..37b2b2b17d --- /dev/null +++ b/src/app/api/playground/presets/route.ts @@ -0,0 +1,116 @@ +/** + * GET /api/playground/presets — list presets + * POST /api/playground/presets — create a preset + * + * Auth: optional (extractApiKey + isValidApiKey; required when REQUIRE_API_KEY=true). + * Hard Rule #5: all DB access via src/lib/db/playgroundPresets (never raw SQL). + * Hard Rule #12: all error paths via buildErrorBody. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; +import { extractApiKey, isValidApiKey } from "@/sse/services/auth"; +import { listPlaygroundPresets, createPlaygroundPreset } from "@/lib/db/playgroundPresets"; +import { PlaygroundPresetCreateSchema } from "@/shared/schemas/playground"; + +const CORS_HEADERS = { + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "*", +}; + +function errorResp(status: number, message: string): Response { + return new Response( + JSON.stringify(buildErrorBody(status, sanitizeErrorMessage(message))), + { + status, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + } + ); +} + +async function checkAuth(request: Request): Promise { + const apiKeyRaw = extractApiKey(request); + if (process.env.REQUIRE_API_KEY === "true" && !apiKeyRaw) { + return errorResp(HTTP_STATUS.UNAUTHORIZED, "Authentication required"); + } + if (apiKeyRaw && !(await isValidApiKey(apiKeyRaw))) { + return errorResp(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); + } + return null; +} + +export async function OPTIONS(): Promise { + return new Response(null, { headers: CORS_HEADERS }); +} + +/** + * GET /api/playground/presets + * Returns { presets: PlaygroundPresetListItem[] } + */ +export async function GET(request: Request): Promise { + const authError = await checkAuth(request); + if (authError) return authError; + + try { + const presets = listPlaygroundPresets(); + return new Response(JSON.stringify({ presets }), { + status: 200, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + } catch (err: unknown) { + const safeMsg = sanitizeErrorMessage( + err instanceof Error ? err.message : "Failed to list presets" + ); + return errorResp(HTTP_STATUS.SERVER_ERROR, safeMsg); + } +} + +/** + * POST /api/playground/presets + * Body: PlaygroundPresetCreateSchema + * Returns the created preset with status 201. + */ +export async function POST(request: Request): Promise { + const authError = await checkAuth(request); + if (authError) return authError; + + // 1. Parse JSON body + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return errorResp(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body"); + } + + // 2. Validate (Hard Rule #7) + const parsed = PlaygroundPresetCreateSchema.safeParse(rawBody); + if (!parsed.success) { + const firstIssue = parsed.error.issues[0]; + const message = firstIssue + ? `${firstIssue.path.join(".") || "body"}: ${firstIssue.message}` + : "Invalid request body"; + return errorResp(HTTP_STATUS.BAD_REQUEST, message); + } + const body = parsed.data; + + // 3. Create preset (Hard Rule #5 — via DB module) + try { + const created = createPlaygroundPreset({ + name: body.name, + endpoint: body.endpoint, + model: body.model, + system: body.system ?? null, + params: body.params, + }); + + return new Response(JSON.stringify(created), { + status: 201, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + } catch (err: unknown) { + const safeMsg = sanitizeErrorMessage( + err instanceof Error ? err.message : "Failed to create preset" + ); + return errorResp(HTTP_STATUS.SERVER_ERROR, safeMsg); + } +} diff --git a/tests/integration/playground-improve-prompt.test.ts b/tests/integration/playground-improve-prompt.test.ts new file mode 100644 index 0000000000..aaa67d67cc --- /dev/null +++ b/tests/integration/playground-improve-prompt.test.ts @@ -0,0 +1,303 @@ +/** + * Integration tests for POST /api/playground/improve-prompt + * + * Tests import the route handler directly and call it with mock Request objects. + * Fetch to /v1/chat/completions is mocked via globalThis.fetch. + * + * Coverage areas: + * - Happy path (system + prompt, only system, only prompt) + * - Invalid bodies → 400 + * - Missing model → 400 + * - Upstream error → sanitized error (no stack trace leak) + * - Hard Rule #12: error.message never contains " at /" + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Set up a temp DATA_DIR so getDbInstance() initialises cleanly +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-improve-prompt-") +); +process.env.DATA_DIR = TEST_DATA_DIR; +// Disable mandatory auth for most tests +process.env.REQUIRE_API_KEY = "false"; + +const { POST, OPTIONS } = await import( + "../../src/app/api/playground/improve-prompt/route.ts" +); + +const BASE_URL = "http://localhost:20128"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeUpstreamOkFetch(content: string, promptTokens = 10, completionTokens = 5) { + return async (_url: unknown, _opts: unknown) => { + const body = JSON.stringify({ + choices: [{ message: { content } }], + usage: { prompt_tokens: promptTokens, completion_tokens: completionTokens }, + }); + return new Response(body, { status: 200, headers: { "Content-Type": "application/json" } }); + }; +} + +function makeUpstreamErrorFetch(status: number, text: string) { + return async (_url: unknown, _opts: unknown) => { + return new Response(text, { status, headers: { "Content-Type": "text/plain" } }); + }; +} + +function postRequest(body: unknown): Request { + return new Request(`${BASE_URL}/api/playground/improve-prompt`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +// ─── Cleanup ───────────────────────────────────────────────────────────────── + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ─── OPTIONS ───────────────────────────────────────────────────────────────── + +test("OPTIONS returns 200 with CORS headers", async () => { + const res = await OPTIONS(); + assert.equal(res.status, 200); + assert.ok(res.headers.get("Access-Control-Allow-Methods")?.includes("POST")); +}); + +// ─── Happy paths ───────────────────────────────────────────────────────────── + +test("happy path: system + prompt both provided", async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = makeUpstreamOkFetch( + "<>\nimproved system\n\n<>\nimproved prompt", + 10, + 5 + ) as typeof fetch; + + const res = await POST( + postRequest({ system: "You are a helper.", prompt: "Tell me about AI.", model: "gpt-4o-mini" }) + ); + assert.equal(res.status, 200); + + const body = (await res.json()) as { + improvedSystem?: string; + improvedPrompt?: string; + tokensIn: number; + tokensOut: number; + }; + assert.equal(body.improvedSystem, "improved system"); + assert.equal(body.improvedPrompt, "improved prompt"); + assert.equal(body.tokensIn, 10); + assert.equal(body.tokensOut, 5); + // CORS header present + assert.ok(res.headers.get("Access-Control-Allow-Methods")?.includes("POST")); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("happy path: only system provided", async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = makeUpstreamOkFetch("improved system content only") as typeof fetch; + + const res = await POST( + postRequest({ system: "You are a helpful assistant.", model: "gpt-4o" }) + ); + assert.equal(res.status, 200); + + const body = (await res.json()) as { + improvedSystem?: string; + improvedPrompt?: string; + tokensIn: number; + tokensOut: number; + }; + // When only system was sent and no markers, content assigned to improvedSystem + assert.ok(typeof body.improvedSystem === "string" || body.improvedSystem === undefined); + assert.strictEqual(body.improvedPrompt, undefined); + assert.equal(typeof body.tokensIn, "number"); + assert.equal(typeof body.tokensOut, "number"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("happy path: only prompt provided", async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = makeUpstreamOkFetch("improved prompt content only") as typeof fetch; + + const res = await POST( + postRequest({ prompt: "What is machine learning?", model: "claude-3-5-sonnet-20241022" }) + ); + assert.equal(res.status, 200); + + const body = (await res.json()) as { + improvedSystem?: string; + improvedPrompt?: string; + tokensIn: number; + tokensOut: number; + }; + assert.strictEqual(body.improvedSystem, undefined); + assert.ok(typeof body.improvedPrompt === "string" || body.improvedPrompt === undefined); + assert.equal(typeof body.tokensIn, "number"); + assert.equal(typeof body.tokensOut, "number"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("happy path: usage defaults to 0 when not in upstream response", async () => { + const originalFetch = globalThis.fetch; + try { + // Return response without usage field + globalThis.fetch = (async (_url: unknown, _opts: unknown) => { + return new Response( + JSON.stringify({ choices: [{ message: { content: "improved" } }] }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; + + const res = await POST( + postRequest({ prompt: "Hello world", model: "gpt-4o-mini" }) + ); + assert.equal(res.status, 200); + + const body = (await res.json()) as { tokensIn: number; tokensOut: number }; + assert.equal(body.tokensIn, 0); + assert.equal(body.tokensOut, 0); + } finally { + globalThis.fetch = originalFetch; + } +}); + +// ─── Validation errors → 400 ───────────────────────────────────────────────── + +test("400 when body has neither system nor prompt", async () => { + const res = await POST(postRequest({ model: "gpt-4o-mini" })); + assert.equal(res.status, 400); + + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error, "should have error field"); + assert.equal(typeof body.error.message, "string"); + // Hard Rule #12: no stack trace + assert.ok(!body.error.message.match(/\sat\s\//), "should not contain stack trace"); +}); + +test("400 when body has both system and prompt as empty strings", async () => { + const res = await POST(postRequest({ system: " ", prompt: " ", model: "gpt-4o-mini" })); + assert.equal(res.status, 400); + + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error, "should have error field"); + assert.ok(!body.error.message.match(/\sat\s\//)); +}); + +test("400 when model is missing", async () => { + const res = await POST(postRequest({ system: "You are a helper." })); + assert.equal(res.status, 400); + + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error, "should have error field"); + assert.ok(!body.error.message.match(/\sat\s\//)); +}); + +test("400 when model is empty string", async () => { + const res = await POST(postRequest({ prompt: "Tell me something.", model: "" })); + assert.equal(res.status, 400); + + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error, "should have error field"); + assert.ok(!body.error.message.match(/\sat\s\//)); +}); + +test("400 when JSON body is malformed", async () => { + const req = new Request(`${BASE_URL}/api/playground/improve-prompt`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "NOT_JSON", + }); + const res = await POST(req); + assert.equal(res.status, 400); + + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error, "should have error field"); + assert.ok(!body.error.message.match(/\sat\s\//)); +}); + +// ─── Upstream error handling ────────────────────────────────────────────────── + +test("upstream error returns sanitized error message — no stack trace in body", async () => { + const originalFetch = globalThis.fetch; + try { + // Simulate upstream returning 500 with a message that looks like stack trace + globalThis.fetch = makeUpstreamErrorFetch( + 500, + "Internal error\n at /home/user/project/src/handler.ts:42:10\n at process.nextTick" + ) as typeof fetch; + + const res = await POST( + postRequest({ prompt: "Hello", model: "gpt-4o-mini" }) + ); + // Should be an error response (not 200) + assert.ok(res.status >= 400); + + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error, "should have error field"); + // Hard Rule #12: stack trace must be stripped + assert.ok( + !body.error.message.match(/\sat\s\//), + "error message must not contain stack trace paths" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("upstream network error is sanitized", async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async () => { + throw new Error("ECONNREFUSED connect ECONNREFUSED 127.0.0.1:20128"); + }) as typeof fetch; + + const res = await POST( + postRequest({ prompt: "Hello", model: "gpt-4o-mini" }) + ); + assert.ok(res.status >= 500); + + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assert.ok(!body.error.message.match(/\sat\s\//)); + } finally { + globalThis.fetch = originalFetch; + } +}); + +// ─── Auth ───────────────────────────────────────────────────────────────────── + +test("401 when REQUIRE_API_KEY=true and no key provided", async () => { + const originalRequired = process.env.REQUIRE_API_KEY; + try { + process.env.REQUIRE_API_KEY = "true"; + const res = await POST( + postRequest({ prompt: "Test", model: "gpt-4o-mini" }) + ); + assert.equal(res.status, 401); + + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assert.ok(!body.error.message.match(/\sat\s\//)); + } finally { + process.env.REQUIRE_API_KEY = originalRequired; + } +}); diff --git a/tests/integration/playground-presets-crud.test.ts b/tests/integration/playground-presets-crud.test.ts new file mode 100644 index 0000000000..655fdfdc53 --- /dev/null +++ b/tests/integration/playground-presets-crud.test.ts @@ -0,0 +1,337 @@ +/** + * Integration tests for GET/POST /api/playground/presets + * and GET/PUT/DELETE /api/playground/presets/[id] + * + * Tests exercise the full CRUD lifecycle using a temporary SQLite DB. + * + * Coverage areas: + * - POST preset → 201 with UUID id + * - GET list → includes the created item + * - GET [id] → returns the item + * - PUT partial patch (name only) + * - DELETE → 204 + * - GET [id] after delete → 404 + * - DELETE non-existent id → 404 + * - UUID validation: non-UUID id → 400 + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Isolated DB per test file +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-presets-crud-") +); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.REQUIRE_API_KEY = "false"; + +const core = await import("../../src/lib/db/core.ts"); + +// Import route handlers +const { GET: listGet, POST: createPost, OPTIONS: listOptions } = await import( + "../../src/app/api/playground/presets/route.ts" +); +const { GET: idGet, PUT: idPut, DELETE: idDelete, OPTIONS: idOptions } = await import( + "../../src/app/api/playground/presets/[id]/route.ts" +); + +const BASE_URL = "http://localhost:20128"; + +const UUID_V4_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function postReq(body: unknown): Request { + return new Request(`${BASE_URL}/api/playground/presets`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function getReq(): Request { + return new Request(`${BASE_URL}/api/playground/presets`, { method: "GET" }); +} + +function idGetReq(id: string): Request { + return new Request(`${BASE_URL}/api/playground/presets/${id}`, { method: "GET" }); +} + +function putReq(id: string, body: unknown): Request { + return new Request(`${BASE_URL}/api/playground/presets/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function deleteReq(id: string): Request { + return new Request(`${BASE_URL}/api/playground/presets/${id}`, { method: "DELETE" }); +} + +async function resolveParams(id: string): Promise<{ params: Promise<{ id: string }> }> { + return { params: Promise.resolve({ id }) }; +} + +// ─── Cleanup ───────────────────────────────────────────────────────────────── + +test.beforeEach(async () => { + // Reset DB between tests for isolation + core.resetDbInstance(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ─── OPTIONS ───────────────────────────────────────────────────────────────── + +test("OPTIONS /presets returns CORS with GET, POST, OPTIONS", async () => { + const res = await listOptions(); + assert.equal(res.status, 200); + const methods = res.headers.get("Access-Control-Allow-Methods") ?? ""; + assert.ok(methods.includes("GET")); + assert.ok(methods.includes("POST")); + assert.ok(methods.includes("OPTIONS")); +}); + +test("OPTIONS /presets/[id] returns CORS with GET, PUT, DELETE, OPTIONS", async () => { + const res = await idOptions(); + assert.equal(res.status, 200); + const methods = res.headers.get("Access-Control-Allow-Methods") ?? ""; + assert.ok(methods.includes("GET")); + assert.ok(methods.includes("PUT")); + assert.ok(methods.includes("DELETE")); +}); + +// ─── Full CRUD lifecycle ────────────────────────────────────────────────────── + +test("POST /presets → 201 with valid UUID id and correct shape", async () => { + const res = await createPost( + postReq({ + name: "My Test Preset", + endpoint: "chat.completions", + model: "gpt-4o", + system: "You are a helpful assistant.", + params: { temperature: 0.7, max_tokens: 1024 }, + }) + ); + assert.equal(res.status, 201); + assert.ok(res.headers.get("Access-Control-Allow-Methods")?.includes("POST")); + + const body = (await res.json()) as { + id: string; + name: string; + endpoint: string; + model: string; + system: string | null; + params: Record; + created_at: string; + }; + assert.ok(UUID_V4_REGEX.test(body.id), "id should be a valid UUID v4"); + assert.equal(body.name, "My Test Preset"); + assert.equal(body.endpoint, "chat.completions"); + assert.equal(body.model, "gpt-4o"); + assert.equal(body.system, "You are a helpful assistant."); + assert.deepEqual(body.params, { temperature: 0.7, max_tokens: 1024 }); + assert.equal(typeof body.created_at, "string"); +}); + +test("GET /presets list returns the created item", async () => { + // Create one preset + const createRes = await createPost( + postReq({ name: "Preset A", endpoint: "chat.completions", model: "gpt-4o" }) + ); + assert.equal(createRes.status, 201); + const created = (await createRes.json()) as { id: string }; + + // List + const listRes = await listGet(getReq()); + assert.equal(listRes.status, 200); + const listBody = (await listRes.json()) as { presets: Array<{ id: string; name: string }> }; + assert.ok(Array.isArray(listBody.presets)); + const found = listBody.presets.find((p) => p.id === created.id); + assert.ok(found, "created preset should appear in list"); + assert.equal(found.name, "Preset A"); +}); + +test("GET /presets/[id] returns the correct preset", async () => { + const createRes = await createPost( + postReq({ name: "Fetch Me", endpoint: "embeddings", model: "text-embedding-3-small" }) + ); + const created = (await createRes.json()) as { id: string; name: string }; + + const getRes = await idGet(idGetReq(created.id), await resolveParams(created.id)); + assert.equal(getRes.status, 200); + + const body = (await getRes.json()) as { id: string; name: string }; + assert.equal(body.id, created.id); + assert.equal(body.name, "Fetch Me"); +}); + +test("PUT /presets/[id] partial patch (name only) updates correctly", async () => { + const createRes = await createPost( + postReq({ name: "Original Name", endpoint: "chat.completions", model: "gpt-4o" }) + ); + const created = (await createRes.json()) as { id: string; endpoint: string; model: string }; + + const putRes = await idPut( + putReq(created.id, { name: "Updated Name" }), + await resolveParams(created.id) + ); + assert.equal(putRes.status, 200); + + const updated = (await putRes.json()) as { id: string; name: string; endpoint: string; model: string }; + assert.equal(updated.id, created.id); + assert.equal(updated.name, "Updated Name"); + // Other fields should be preserved + assert.equal(updated.endpoint, created.endpoint); + assert.equal(updated.model, created.model); +}); + +test("PUT /presets/[id] can update params", async () => { + const createRes = await createPost( + postReq({ + name: "Params Test", + endpoint: "chat.completions", + model: "gpt-4o", + params: { temperature: 0.5 }, + }) + ); + const created = (await createRes.json()) as { id: string }; + + const putRes = await idPut( + putReq(created.id, { params: { temperature: 0.9, max_tokens: 2048 } }), + await resolveParams(created.id) + ); + assert.equal(putRes.status, 200); + + const updated = (await putRes.json()) as { params: Record }; + assert.deepEqual(updated.params, { temperature: 0.9, max_tokens: 2048 }); +}); + +test("DELETE /presets/[id] returns 204", async () => { + const createRes = await createPost( + postReq({ name: "Delete Me", endpoint: "chat.completions", model: "gpt-4o" }) + ); + const created = (await createRes.json()) as { id: string }; + + const delRes = await idDelete(deleteReq(created.id), await resolveParams(created.id)); + assert.equal(delRes.status, 204); +}); + +test("GET /presets/[id] after DELETE returns 404", async () => { + const createRes = await createPost( + postReq({ name: "Gone Soon", endpoint: "chat.completions", model: "gpt-4o" }) + ); + const created = (await createRes.json()) as { id: string }; + + await idDelete(deleteReq(created.id), await resolveParams(created.id)); + + const getRes = await idGet(idGetReq(created.id), await resolveParams(created.id)); + assert.equal(getRes.status, 404); + + const body = (await getRes.json()) as { error: { message: string } }; + assert.ok(body.error); + assert.ok(!body.error.message.match(/\sat\s\//)); +}); + +test("DELETE /presets/[id] with non-existent id returns 404", async () => { + const fakeId = "00000000-0000-4000-8000-000000000000"; + const delRes = await idDelete(deleteReq(fakeId), await resolveParams(fakeId)); + assert.equal(delRes.status, 404); + + const body = (await delRes.json()) as { error: { message: string } }; + assert.ok(body.error); + assert.ok(!body.error.message.match(/\sat\s\//)); +}); + +// ─── params JSON round-trip ─────────────────────────────────────────────────── + +test("params object is serialized and deserialized correctly", async () => { + const complexParams = { + temperature: 0.7, + max_tokens: 2048, + top_p: 0.9, + presence_penalty: 0.1, + frequency_penalty: 0.2, + stop: ["<|end|>", "\n\n"], + }; + + const createRes = await createPost( + postReq({ + name: "Complex Params", + endpoint: "chat.completions", + model: "gpt-4o", + params: complexParams, + }) + ); + assert.equal(createRes.status, 201); + const created = (await createRes.json()) as { id: string; params: Record }; + assert.deepEqual(created.params, complexParams); + + // GET again and verify + const getRes = await idGet(idGetReq(created.id), await resolveParams(created.id)); + const fetched = (await getRes.json()) as { params: Record }; + assert.deepEqual(fetched.params, complexParams); +}); + +// ─── UUID validation ───────────────────────────────────────────────────────── + +test("GET /presets/[id] with non-UUID id → 400", async () => { + const badId = "not-a-uuid"; + const res = await idGet(idGetReq(badId), await resolveParams(badId)); + assert.equal(res.status, 400); + + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assert.ok(!body.error.message.match(/\sat\s\//)); +}); + +test("PUT /presets/[id] with non-UUID id → 400", async () => { + const badId = "also-not-a-uuid"; + const res = await idPut( + putReq(badId, { name: "Whatever" }), + await resolveParams(badId) + ); + assert.equal(res.status, 400); + + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assert.ok(!body.error.message.match(/\sat\s\//)); +}); + +test("DELETE /presets/[id] with non-UUID id → 400", async () => { + const badId = "bad-id-not-uuid"; + const res = await idDelete(deleteReq(badId), await resolveParams(badId)); + assert.equal(res.status, 400); + + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assert.ok(!body.error.message.match(/\sat\s\//)); +}); + +// ─── Null system ───────────────────────────────────────────────────────────── + +test("POST with null system stores null correctly", async () => { + const res = await createPost( + postReq({ name: "No System", endpoint: "chat.completions", model: "gpt-4o", system: null }) + ); + assert.equal(res.status, 201); + const body = (await res.json()) as { system: string | null }; + assert.strictEqual(body.system, null); +}); + +test("POST without system defaults to null", async () => { + const res = await createPost( + postReq({ name: "No System Either", endpoint: "chat.completions", model: "gpt-4o" }) + ); + assert.equal(res.status, 201); + const body = (await res.json()) as { system: string | null }; + assert.strictEqual(body.system, null); +}); diff --git a/tests/integration/playground-presets-zod.test.ts b/tests/integration/playground-presets-zod.test.ts new file mode 100644 index 0000000000..3150eaed15 --- /dev/null +++ b/tests/integration/playground-presets-zod.test.ts @@ -0,0 +1,299 @@ +/** + * Integration tests for Zod validation in playground presets routes. + * + * Tests focus on invalid bodies and edge cases that must return 400. + * + * Coverage areas: + * - POST with empty name → 400 + * - POST with missing required fields (endpoint, model) → 400 + * - POST with system > 50000 chars → 400 + * - PUT with invalid body → 400 + * - GET/PUT/DELETE with valid UUID format but wrong format string → 400 + * - All error responses must NOT contain stack trace fragments (Hard Rule #12) + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Isolated DB per test file +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-presets-zod-") +); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.REQUIRE_API_KEY = "false"; + +const core = await import("../../src/lib/db/core.ts"); + +const { POST: createPost } = await import( + "../../src/app/api/playground/presets/route.ts" +); +const { GET: idGet, PUT: idPut, DELETE: idDelete } = await import( + "../../src/app/api/playground/presets/[id]/route.ts" +); + +const BASE_URL = "http://localhost:20128"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function postReq(body: unknown): Request { + return new Request(`${BASE_URL}/api/playground/presets`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function putReq(id: string, body: unknown): Request { + return new Request(`${BASE_URL}/api/playground/presets/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function getReq(id: string): Request { + return new Request(`${BASE_URL}/api/playground/presets/${id}`, { method: "GET" }); +} + +function deleteReq(id: string): Request { + return new Request(`${BASE_URL}/api/playground/presets/${id}`, { method: "DELETE" }); +} + +async function resolveParams(id: string): Promise<{ params: Promise<{ id: string }> }> { + return { params: Promise.resolve({ id }) }; +} + +// Sanitization assertion helper +function assertNoStackTrace(body: { error?: { message?: string } }): void { + const msg = body.error?.message ?? ""; + assert.ok( + !msg.match(/\sat\s\//), + `error message must not contain stack trace paths; got: ${msg}` + ); +} + +// ─── Cleanup ───────────────────────────────────────────────────────────────── + +test.beforeEach(() => { + core.resetDbInstance(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ─── POST validation ───────────────────────────────────────────────────────── + +test("POST with empty name → 400 with Zod error", async () => { + const res = await createPost( + postReq({ name: "", endpoint: "chat.completions", model: "gpt-4o" }) + ); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("POST with name exceeding 100 chars → 400", async () => { + const res = await createPost( + postReq({ name: "x".repeat(101), endpoint: "chat.completions", model: "gpt-4o" }) + ); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("POST with missing endpoint → 400", async () => { + const res = await createPost(postReq({ name: "Test", model: "gpt-4o" })); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("POST with empty endpoint → 400", async () => { + const res = await createPost(postReq({ name: "Test", endpoint: "", model: "gpt-4o" })); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("POST with missing model → 400", async () => { + const res = await createPost(postReq({ name: "Test", endpoint: "chat.completions" })); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("POST with empty model → 400", async () => { + const res = await createPost( + postReq({ name: "Test", endpoint: "chat.completions", model: "" }) + ); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("POST with system > 50000 chars → 400", async () => { + const longSystem = "x".repeat(50001); + const res = await createPost( + postReq({ name: "Big System", endpoint: "chat.completions", model: "gpt-4o", system: longSystem }) + ); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("POST with system exactly 50000 chars → 201 (boundary: valid)", async () => { + const maxSystem = "x".repeat(50000); + const res = await createPost( + postReq({ name: "Max System", endpoint: "chat.completions", model: "gpt-4o", system: maxSystem }) + ); + assert.equal(res.status, 201); +}); + +test("POST with invalid JSON body → 400", async () => { + const req = new Request(`${BASE_URL}/api/playground/presets`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "GARBAGE", + }); + const res = await createPost(req); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("POST with missing name entirely → 400", async () => { + const res = await createPost(postReq({ endpoint: "chat.completions", model: "gpt-4o" })); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +// ─── PUT validation ─────────────────────────────────────────────────────────── + +test("PUT with invalid body (name is number) → 400", async () => { + const validId = "00000000-0000-4000-8000-000000000001"; + const res = await idPut(putReq(validId, { name: 42 }), await resolveParams(validId)); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("PUT with empty name → 400", async () => { + const validId = "00000000-0000-4000-8000-000000000001"; + const res = await idPut(putReq(validId, { name: "" }), await resolveParams(validId)); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("PUT with system > 50000 chars → 400", async () => { + const validId = "00000000-0000-4000-8000-000000000001"; + const longSystem = "y".repeat(50001); + const res = await idPut( + putReq(validId, { system: longSystem }), + await resolveParams(validId) + ); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("PUT with invalid JSON body → 400", async () => { + const validId = "00000000-0000-4000-8000-000000000001"; + const req = new Request(`${BASE_URL}/api/playground/presets/${validId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: "GARBAGE", + }); + const res = await idPut(req, await resolveParams(validId)); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +// ─── UUID format validation ─────────────────────────────────────────────────── + +test("GET /presets/[id] with numeric string → 400", async () => { + const badId = "12345"; + const res = await idGet(getReq(badId), await resolveParams(badId)); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("GET /presets/[id] with plain word id → 400", async () => { + const badId = "my-preset"; + const res = await idGet(getReq(badId), await resolveParams(badId)); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("PUT /presets/[id] with numeric string id → 400", async () => { + const badId = "12345"; + const res = await idPut(putReq(badId, { name: "x" }), await resolveParams(badId)); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("DELETE /presets/[id] with numeric string id → 400", async () => { + const badId = "99999"; + const res = await idDelete(deleteReq(badId), await resolveParams(badId)); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +test("GET /presets/[id] with UUID v1 format → 404 (valid UUID format, not found in DB)", async () => { + // z.string().uuid() accepts any RFC-4122 UUID (v1, v4, etc). + // UUID v1 passes UUID validation → route proceeds to DB lookup → 404 (not found). + const uuidV1 = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"; + const res = await idGet(getReq(uuidV1), await resolveParams(uuidV1)); + assert.equal(res.status, 404); + const body = (await res.json()) as { error: { message: string } }; + assert.ok(body.error); + assertNoStackTrace(body); +}); + +// ─── Error response shape validation ───────────────────────────────────────── + +test("all 400 responses have consistent error shape (message string)", async () => { + const cases = [ + createPost(postReq({ name: "" })), + createPost(postReq({ endpoint: "chat" })), + createPost(postReq({})), + ]; + + for (const pending of cases) { + const res = await pending; + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { message: string; type?: string } }; + assert.ok(body.error, "should have error object"); + assert.equal(typeof body.error.message, "string", "error.message must be a string"); + assert.ok(body.error.message.length > 0, "error.message must not be empty"); + assertNoStackTrace(body); + } +});