From ae93cfbee71698a488af3d62406bd96240775d16 Mon Sep 17 00:00:00 2001 From: Jan Leon Date: Tue, 30 Jun 2026 03:08:27 +0200 Subject: [PATCH] chore(duplication): share service install helpers (#5495) Share service install helpers; re-add SERVICE_VERSION_PATTERN regex to the shared schema (dropped in extraction, #5474) + tests rejecting malformed versions. Co-authored-by: diegosouzapw --- src/app/api/services/9router/install/route.ts | 41 +----- src/app/api/services/_shared/installRoute.ts | 79 ++++++++++++ .../api/services/cliproxy/install/route.ts | 38 +----- .../services/install-route-helper.test.ts | 121 ++++++++++++++++++ 4 files changed, 206 insertions(+), 73 deletions(-) create mode 100644 src/app/api/services/_shared/installRoute.ts create mode 100644 tests/unit/services/install-route-helper.test.ts diff --git a/src/app/api/services/9router/install/route.ts b/src/app/api/services/9router/install/route.ts index ee4a236489..2a11259c4a 100644 --- a/src/app/api/services/9router/install/route.ts +++ b/src/app/api/services/9router/install/route.ts @@ -1,41 +1,6 @@ -import { z } from "zod"; -import { install, InstallResult } from "@/lib/services/installers/ninerouter"; -import { InstallError, SERVICE_VERSION_PATTERN } from "@/lib/services/installers/utils"; -import { createErrorResponse } from "@/lib/api/errorResponse"; -import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; - -const BodySchema = z.object({ - version: z - .string() - .regex(SERVICE_VERSION_PATTERN, "Invalid version: only letters, digits and . _ + - are allowed") - .optional() - .default("latest"), -}); +import { install } from "@/lib/services/installers/ninerouter"; +import { handleServiceInstall } from "@/app/api/services/_shared/installRoute"; export async function POST(request: Request): Promise { - let body: unknown; - try { - body = request.body === null ? {} : await request.json(); - } catch { - return createErrorResponse({ status: 400, message: "Invalid JSON body" }); - } - - const parsed = BodySchema.safeParse(body); - if (!parsed.success) { - return createErrorResponse({ status: 400, message: parsed.error.message }); - } - - try { - const result: InstallResult = await install(parsed.data.version); - return Response.json({ ok: true, ...result }); - } catch (err) { - if (err instanceof InstallError) { - return createErrorResponse({ - status: err.httpStatus, - message: err.friendly, - }); - } - const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); - return createErrorResponse({ status: 500, message: msg }); - } + return handleServiceInstall(request, install); } diff --git a/src/app/api/services/_shared/installRoute.ts b/src/app/api/services/_shared/installRoute.ts new file mode 100644 index 0000000000..5d23725799 --- /dev/null +++ b/src/app/api/services/_shared/installRoute.ts @@ -0,0 +1,79 @@ +import { z } from "zod"; + +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { InstallError, SERVICE_VERSION_PATTERN } from "@/lib/services/installers/utils"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +export type ServiceInstallResult = { + installedVersion: string; + installPath: string; + durationMs: number; +}; + +export type ServiceInstaller = (version: string) => Promise; + +const installBodySchema = z.object({ + // Keep the version constrained by SERVICE_VERSION_PATTERN — the per-route schemas + // enforced this before the extraction (#5474); dropping it here would let strings + // like "../../malicious" reach the installer (#5495). + version: z + .string() + .regex(SERVICE_VERSION_PATTERN, "Invalid version: only letters, digits and . _ + - are allowed") + .optional() + .default("latest"), +}); + +export async function readServiceInstallVersion(request: Request): Promise< + | { + ok: true; + version: string; + } + | { + ok: false; + response: Response; + } +> { + let body: unknown; + try { + body = request.body === null ? {} : await request.json(); + } catch { + return { + ok: false, + response: createErrorResponse({ status: 400, message: "Invalid JSON body" }), + }; + } + + const parsed = installBodySchema.safeParse(body); + if (!parsed.success) { + return { + ok: false, + response: createErrorResponse({ status: 400, message: parsed.error.message }), + }; + } + + return { ok: true, version: parsed.data.version }; +} + +export async function handleServiceInstall( + request: Request, + install: ServiceInstaller +): Promise { + const parsed = await readServiceInstallVersion(request); + if (!parsed.ok) { + return parsed.response; + } + + try { + const result = await install(parsed.version); + return Response.json({ ok: true, ...result }); + } catch (err) { + if (err instanceof InstallError) { + return createErrorResponse({ + status: err.httpStatus, + message: err.friendly, + }); + } + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 500, message: msg }); + } +} diff --git a/src/app/api/services/cliproxy/install/route.ts b/src/app/api/services/cliproxy/install/route.ts index a3ee88bc36..5b463fd856 100644 --- a/src/app/api/services/cliproxy/install/route.ts +++ b/src/app/api/services/cliproxy/install/route.ts @@ -1,38 +1,6 @@ -import { z } from "zod"; -import { install, InstallResult } from "@/lib/services/installers/cliproxy"; -import { InstallError, SERVICE_VERSION_PATTERN } from "@/lib/services/installers/utils"; -import { createErrorResponse } from "@/lib/api/errorResponse"; -import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; - -const BodySchema = z.object({ - version: z - .string() - .regex(SERVICE_VERSION_PATTERN, "Invalid version: only letters, digits and . _ + - are allowed") - .optional() - .default("latest"), -}); +import { install } from "@/lib/services/installers/cliproxy"; +import { handleServiceInstall } from "@/app/api/services/_shared/installRoute"; export async function POST(request: Request): Promise { - let body: unknown; - try { - body = request.body === null ? {} : await request.json(); - } catch { - return createErrorResponse({ status: 400, message: "Invalid JSON body" }); - } - - const parsed = BodySchema.safeParse(body); - if (!parsed.success) { - return createErrorResponse({ status: 400, message: parsed.error.message }); - } - - try { - const result: InstallResult = await install(parsed.data.version); - return Response.json({ ok: true, ...result }); - } catch (err) { - if (err instanceof InstallError) { - return createErrorResponse({ status: err.httpStatus, message: err.friendly }); - } - const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); - return createErrorResponse({ status: 500, message: msg }); - } + return handleServiceInstall(request, install); } diff --git a/tests/unit/services/install-route-helper.test.ts b/tests/unit/services/install-route-helper.test.ts new file mode 100644 index 0000000000..e3df61d733 --- /dev/null +++ b/tests/unit/services/install-route-helper.test.ts @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + handleServiceInstall, + readServiceInstallVersion, +} from "../../../src/app/api/services/_shared/installRoute.ts"; +import { InstallError } from "../../../src/lib/services/installers/utils.ts"; + +async function readJson(response: Response) { + return (await response.json()) as Record; +} + +test("readServiceInstallVersion defaults empty requests to latest", async () => { + const result = await readServiceInstallVersion( + new Request("http://localhost/api/services/example/install", { method: "POST" }) + ); + + assert.deepEqual(result, { ok: true, version: "latest" }); +}); + +test("readServiceInstallVersion returns the requested version", async () => { + const result = await readServiceInstallVersion( + new Request("http://localhost/api/services/example/install", { + method: "POST", + body: JSON.stringify({ version: "1.2.3" }), + headers: { "Content-Type": "application/json" }, + }) + ); + + assert.deepEqual(result, { ok: true, version: "1.2.3" }); +}); + +test("readServiceInstallVersion rejects malformed versions (#5495 SERVICE_VERSION_PATTERN guard)", async () => { + const result = await readServiceInstallVersion( + new Request("http://localhost/api/services/example/install", { + method: "POST", + body: JSON.stringify({ version: "../../malicious" }), + headers: { "Content-Type": "application/json" }, + }) + ); + + assert.equal(result.ok, false); + if (result.ok) throw new Error("expected version validation failure"); + assert.equal(result.response.status, 400); +}); + +test("handleServiceInstall never reaches the installer for a malformed version (#5495)", async () => { + const calls: string[] = []; + const response = await handleServiceInstall( + new Request("http://localhost/api/services/example/install", { + method: "POST", + body: JSON.stringify({ version: "v1; rm -rf /" }), + headers: { "Content-Type": "application/json" }, + }), + async (version) => { + calls.push(version); + return { installedVersion: version, installPath: "/tmp/service", durationMs: 1 }; + } + ); + + assert.deepEqual(calls, []); + assert.equal(response.status, 400); +}); + +test("readServiceInstallVersion preserves invalid JSON error shape", async () => { + const result = await readServiceInstallVersion( + new Request("http://localhost/api/services/example/install", { + method: "POST", + body: "not-json", + headers: { "Content-Type": "application/json" }, + }) + ); + + assert.equal(result.ok, false); + if (result.ok) throw new Error("expected parse failure"); + assert.equal(result.response.status, 400); + const body = await readJson(result.response); + assert.equal((body.error as Record).message, "Invalid JSON body"); +}); + +test("handleServiceInstall wraps successful installer results", async () => { + const calls: string[] = []; + const response = await handleServiceInstall( + new Request("http://localhost/api/services/example/install", { + method: "POST", + body: JSON.stringify({ version: "2.0.0" }), + headers: { "Content-Type": "application/json" }, + }), + async (version) => { + calls.push(version); + return { + installedVersion: version, + installPath: "/tmp/service", + durationMs: 42, + }; + } + ); + + assert.deepEqual(calls, ["2.0.0"]); + assert.equal(response.status, 200); + assert.deepEqual(await readJson(response), { + ok: true, + installedVersion: "2.0.0", + installPath: "/tmp/service", + durationMs: 42, + }); +}); + +test("handleServiceInstall maps InstallError to its friendly message and status", async () => { + const response = await handleServiceInstall( + new Request("http://localhost/api/services/example/install", { method: "POST" }), + async () => { + throw new InstallError("raw command failed", "Friendly install failure", 503); + } + ); + + assert.equal(response.status, 503); + const body = await readJson(response); + assert.equal((body.error as Record).message, "Friendly install failure"); +});