From 152ffc2ebf029652ef876a1b9499a41d8372aea1 Mon Sep 17 00:00:00 2001 From: Jan Leon Date: Tue, 30 Jun 2026 03:10:43 +0200 Subject: [PATCH] chore(duplication): share proxy route handlers (#5472) Share proxy route handlers; add resolveProxyLookupResponse regression test (3 branches + custom whereUsed param name). Co-authored-by: diegosouzapw --- src/app/api/settings/proxies/route.ts | 138 ++--------------- src/app/api/v1/management/proxies/route.ts | 141 ++--------------- src/lib/api/proxyRegistryRouteHandlers.ts | 142 ++++++++++++++++++ .../proxy-registry-route-handlers.test.ts | 93 ++++++++++++ 4 files changed, 259 insertions(+), 255 deletions(-) create mode 100644 src/lib/api/proxyRegistryRouteHandlers.ts create mode 100644 tests/unit/proxy-registry-route-handlers.test.ts diff --git a/src/app/api/settings/proxies/route.ts b/src/app/api/settings/proxies/route.ts index 4bb156f9b6..62abcaf344 100644 --- a/src/app/api/settings/proxies/route.ts +++ b/src/app/api/settings/proxies/route.ts @@ -1,39 +1,20 @@ +import { listProxies } from "@/lib/localDb"; import { - createProxy, - createProxyAndAssign, - deleteProxyById, - getProxyById, - getProxyWhereUsed, - listProxies, - updateProxy, - updateProxyAndAssign, -} from "@/lib/localDb"; -import { createProxyRegistrySchema, updateProxyRegistrySchema } from "@/shared/validation/schemas"; -import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; -import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; + handleProxyCreate, + handleProxyDelete, + handleProxyUpdate, + resolveProxyLookupResponse, +} from "@/lib/api/proxyRegistryRouteHandlers"; +import { createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; -import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher"; export async function GET(request: Request) { const authError = await requireManagementAuth(request); if (authError) return authError; try { const { searchParams } = new URL(request.url); - const id = searchParams.get("id"); - const whereUsed = searchParams.get("whereUsed") === "1"; - - if (id && whereUsed) { - const usage = await getProxyWhereUsed(id); - return Response.json(usage); - } - - if (id) { - const proxy = await getProxyById(id, { includeSecrets: false }); - if (!proxy) { - return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" }); - } - return Response.json(proxy); - } + const lookupResponse = await resolveProxyLookupResponse(searchParams, "whereUsed"); + if (lookupResponse) return lookupResponse; const proxies = await listProxies({ includeSecrets: false }); // #3508: expose the SOCKS5 feature flag at runtime so the dashboard reflects the live @@ -55,112 +36,17 @@ export async function GET(request: Request) { export async function POST(request: Request) { const authError = await requireManagementAuth(request); if (authError) return authError; - let rawBody: unknown; - try { - rawBody = await request.json(); - } catch { - return createErrorResponse({ - status: 400, - message: "Invalid JSON body", - type: "invalid_request", - }); - } - - try { - const validation = validateBody(createProxyRegistrySchema, rawBody); - if (isValidationFailure(validation)) { - return createErrorResponse({ - status: 400, - message: validation.error.message, - details: validation.error.details, - type: "invalid_request", - }); - } - - const { assignment, ...proxyFields } = validation.data; - if (assignment) { - const result = await createProxyAndAssign(proxyFields, assignment); - clearDispatcherCache(); - return Response.json({ ...result.proxy, assignment: result.assignment }, { status: 201 }); - } - - const created = await createProxy(proxyFields); - return Response.json(created, { status: 201 }); - } catch (error) { - return createErrorResponseFromUnknown(error, "Failed to create proxy"); - } + return handleProxyCreate(request); } export async function PATCH(request: Request) { const authError = await requireManagementAuth(request); if (authError) return authError; - let rawBody: unknown; - try { - rawBody = await request.json(); - } catch { - return createErrorResponse({ - status: 400, - message: "Invalid JSON body", - type: "invalid_request", - }); - } - - try { - const validation = validateBody(updateProxyRegistrySchema, rawBody); - if (isValidationFailure(validation)) { - return createErrorResponse({ - status: 400, - message: validation.error.message, - details: validation.error.details, - type: "invalid_request", - }); - } - - const { id, assignment, ...changes } = validation.data; - if (assignment) { - const result = await updateProxyAndAssign(id, changes, assignment); - if (!result?.proxy) { - return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" }); - } - - clearDispatcherCache(); - return Response.json({ ...result.proxy, assignment: result.assignment }); - } - - const updated = await updateProxy(id, changes); - if (!updated) { - return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" }); - } - - return Response.json(updated); - } catch (error) { - return createErrorResponseFromUnknown(error, "Failed to update proxy"); - } + return handleProxyUpdate(request); } export async function DELETE(request: Request) { const authError = await requireManagementAuth(request); if (authError) return authError; - try { - const { searchParams } = new URL(request.url); - const id = searchParams.get("id"); - const force = searchParams.get("force") === "1"; - - if (!id) { - return createErrorResponse({ - status: 400, - message: "id is required", - type: "invalid_request", - }); - } - - const deleted = await deleteProxyById(id, { force }); - if (!deleted) { - return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" }); - } - - return Response.json({ success: true }); - } catch (error) { - return createErrorResponseFromUnknown(error, "Failed to delete proxy"); - } + return handleProxyDelete(request); } diff --git a/src/app/api/v1/management/proxies/route.ts b/src/app/api/v1/management/proxies/route.ts index 6ab5e69655..29a49101d3 100644 --- a/src/app/api/v1/management/proxies/route.ts +++ b/src/app/api/v1/management/proxies/route.ts @@ -1,18 +1,12 @@ +import { listProxies } from "@/lib/localDb"; import { - createProxy, - createProxyAndAssign, - deleteProxyById, - getProxyById, - getProxyWhereUsed, - listProxies, - updateProxy, - updateProxyAndAssign, -} from "@/lib/localDb"; -import { createProxyRegistrySchema, updateProxyRegistrySchema } from "@/shared/validation/schemas"; -import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; -import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; + handleProxyCreate, + handleProxyDelete, + handleProxyUpdate, + resolveProxyLookupResponse, +} from "@/lib/api/proxyRegistryRouteHandlers"; +import { createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; -import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher"; function toPagination(searchParams: URLSearchParams) { const limit = Math.max(1, Math.min(200, Number(searchParams.get("limit") || 50))); @@ -26,21 +20,8 @@ export async function GET(request: Request) { try { const { searchParams } = new URL(request.url); - const id = searchParams.get("id"); - const whereUsed = searchParams.get("where_used") === "1"; - - if (id && whereUsed) { - const usage = await getProxyWhereUsed(id); - return Response.json(usage); - } - - if (id) { - const proxy = await getProxyById(id, { includeSecrets: false }); - if (!proxy) { - return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" }); - } - return Response.json(proxy); - } + const lookupResponse = await resolveProxyLookupResponse(searchParams, "where_used"); + if (lookupResponse) return lookupResponse; const { limit, offset } = toPagination(searchParams); const items = await listProxies({ includeSecrets: false }); @@ -57,115 +38,17 @@ export async function GET(request: Request) { export async function POST(request: Request) { const authError = await requireManagementAuth(request); if (authError) return authError; - - let rawBody: unknown; - try { - rawBody = await request.json(); - } catch { - return createErrorResponse({ - status: 400, - message: "Invalid JSON body", - type: "invalid_request", - }); - } - - try { - const validation = validateBody(createProxyRegistrySchema, rawBody); - if (isValidationFailure(validation)) { - return createErrorResponse({ - status: 400, - message: validation.error.message, - details: validation.error.details, - type: "invalid_request", - }); - } - - const { assignment, ...proxyFields } = validation.data; - if (assignment) { - const result = await createProxyAndAssign(proxyFields, assignment); - clearDispatcherCache(); - return Response.json({ ...result.proxy, assignment: result.assignment }, { status: 201 }); - } - - const created = await createProxy(proxyFields); - return Response.json(created, { status: 201 }); - } catch (error) { - return createErrorResponseFromUnknown(error, "Failed to create proxy"); - } + return handleProxyCreate(request); } export async function PATCH(request: Request) { const authError = await requireManagementAuth(request); if (authError) return authError; - - let rawBody: unknown; - try { - rawBody = await request.json(); - } catch { - return createErrorResponse({ - status: 400, - message: "Invalid JSON body", - type: "invalid_request", - }); - } - - try { - const validation = validateBody(updateProxyRegistrySchema, rawBody); - if (isValidationFailure(validation)) { - return createErrorResponse({ - status: 400, - message: validation.error.message, - details: validation.error.details, - type: "invalid_request", - }); - } - - const { id, assignment, ...changes } = validation.data; - if (assignment) { - const result = await updateProxyAndAssign(id, changes, assignment); - if (!result?.proxy) { - return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" }); - } - - clearDispatcherCache(); - return Response.json({ ...result.proxy, assignment: result.assignment }); - } - - const updated = await updateProxy(id, changes); - if (!updated) { - return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" }); - } - - return Response.json(updated); - } catch (error) { - return createErrorResponseFromUnknown(error, "Failed to update proxy"); - } + return handleProxyUpdate(request); } export async function DELETE(request: Request) { const authError = await requireManagementAuth(request); if (authError) return authError; - - try { - const { searchParams } = new URL(request.url); - const id = searchParams.get("id"); - const force = searchParams.get("force") === "1"; - - if (!id) { - return createErrorResponse({ - status: 400, - message: "id is required", - type: "invalid_request", - }); - } - - const deleted = await deleteProxyById(id, { force }); - if (!deleted) { - return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" }); - } - - return Response.json({ success: true }); - } catch (error) { - return createErrorResponseFromUnknown(error, "Failed to delete proxy"); - } + return handleProxyDelete(request); } diff --git a/src/lib/api/proxyRegistryRouteHandlers.ts b/src/lib/api/proxyRegistryRouteHandlers.ts new file mode 100644 index 0000000000..dc93873956 --- /dev/null +++ b/src/lib/api/proxyRegistryRouteHandlers.ts @@ -0,0 +1,142 @@ +import { + createProxy, + createProxyAndAssign, + deleteProxyById, + getProxyById, + getProxyWhereUsed, + updateProxy, + updateProxyAndAssign, +} from "@/lib/localDb"; +import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { createProxyRegistrySchema, updateProxyRegistrySchema } from "@/shared/validation/schemas"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher"; + +async function readJsonBody(request: Request) { + try { + return { ok: true as const, body: await request.json() }; + } catch { + return { + ok: false as const, + response: createErrorResponse({ + status: 400, + message: "Invalid JSON body", + type: "invalid_request", + }), + }; + } +} + +export async function resolveProxyLookupResponse( + searchParams: URLSearchParams, + whereUsedParam: string +): Promise { + const id = searchParams.get("id"); + const whereUsed = searchParams.get(whereUsedParam) === "1"; + + if (id && whereUsed) { + const usage = await getProxyWhereUsed(id); + return Response.json(usage); + } + + if (!id) { + return null; + } + + const proxy = await getProxyById(id, { includeSecrets: false }); + if (!proxy) { + return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" }); + } + return Response.json(proxy); +} + +export async function handleProxyCreate(request: Request) { + const parsed = await readJsonBody(request); + if (!parsed.ok) return parsed.response; + + try { + const validation = validateBody(createProxyRegistrySchema, parsed.body); + if (isValidationFailure(validation)) { + return createErrorResponse({ + status: 400, + message: validation.error.message, + details: validation.error.details, + type: "invalid_request", + }); + } + + const { assignment, ...proxyFields } = validation.data; + if (assignment) { + const result = await createProxyAndAssign(proxyFields, assignment); + clearDispatcherCache(); + return Response.json({ ...result.proxy, assignment: result.assignment }, { status: 201 }); + } + + const created = await createProxy(proxyFields); + return Response.json(created, { status: 201 }); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to create proxy"); + } +} + +export async function handleProxyUpdate(request: Request) { + const parsed = await readJsonBody(request); + if (!parsed.ok) return parsed.response; + + try { + const validation = validateBody(updateProxyRegistrySchema, parsed.body); + if (isValidationFailure(validation)) { + return createErrorResponse({ + status: 400, + message: validation.error.message, + details: validation.error.details, + type: "invalid_request", + }); + } + + const { id, assignment, ...changes } = validation.data; + if (assignment) { + const result = await updateProxyAndAssign(id, changes, assignment); + if (!result?.proxy) { + return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" }); + } + + clearDispatcherCache(); + return Response.json({ ...result.proxy, assignment: result.assignment }); + } + + const updated = await updateProxy(id, changes); + if (!updated) { + return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" }); + } + + return Response.json(updated); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to update proxy"); + } +} + +export async function handleProxyDelete(request: Request) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + const force = searchParams.get("force") === "1"; + + if (!id) { + return createErrorResponse({ + status: 400, + message: "id is required", + type: "invalid_request", + }); + } + + const deleted = await deleteProxyById(id, { force }); + if (!deleted) { + return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" }); + } + + return Response.json({ success: true }); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to delete proxy"); + } +} diff --git a/tests/unit/proxy-registry-route-handlers.test.ts b/tests/unit/proxy-registry-route-handlers.test.ts new file mode 100644 index 0000000000..b1b66b9c10 --- /dev/null +++ b/tests/unit/proxy-registry-route-handlers.test.ts @@ -0,0 +1,93 @@ +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"; + +// Regression coverage for the shared proxy-route handlers extracted in #5472. +// resolveProxyLookupResponse is the single point of truth for the GET branch of +// both /api/settings/proxies (whereUsed param: "whereUsed") and +// /api/v1/management/proxies (whereUsed param: "where_used"), so its three +// branches — id+whereUsed, id-only, list (no id) — must stay equivalent across +// the parameterized callers. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-route-handlers-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const proxiesDb = await import("../../src/lib/db/proxies.ts"); +const { resolveProxyLookupResponse } = await import( + "../../src/lib/api/proxyRegistryRouteHandlers.ts" +); + +async function resetStorage() { + delete process.env.INITIAL_PASSWORD; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("resolveProxyLookupResponse returns null for the list path (no id)", async () => { + await resetStorage(); + const result = await resolveProxyLookupResponse(new URLSearchParams(), "whereUsed"); + assert.equal(result, null); +}); + +test("resolveProxyLookupResponse returns the proxy when id matches", async () => { + await resetStorage(); + const created = await proxiesDb.createProxy({ + name: "Lookup Proxy", + type: "http", + host: "127.0.0.1", + port: 8080, + }); + assert.ok(created?.id); + + const result = await resolveProxyLookupResponse( + new URLSearchParams({ id: created.id }), + "whereUsed" + ); + assert.ok(result instanceof Response); + assert.equal(result.status, 200); + const body = (await result.json()) as Record; + assert.equal(body.id, created.id); + assert.equal(body.name, "Lookup Proxy"); +}); + +test("resolveProxyLookupResponse returns 404 for an unknown id", async () => { + await resetStorage(); + const result = await resolveProxyLookupResponse( + new URLSearchParams({ id: "does-not-exist" }), + "whereUsed" + ); + assert.ok(result instanceof Response); + assert.equal(result.status, 404); +}); + +test("resolveProxyLookupResponse honors the caller's whereUsed param name (where_used)", async () => { + await resetStorage(); + const created = await proxiesDb.createProxy({ + name: "Usage Proxy", + type: "http", + host: "127.0.0.1", + port: 9090, + }); + assert.ok(created?.id); + + // management route uses the snake_case param name; the usage branch must fire. + const result = await resolveProxyLookupResponse( + new URLSearchParams({ id: created.id, where_used: "1" }), + "where_used" + ); + assert.ok(result instanceof Response); + assert.equal(result.status, 200); + // getProxyWhereUsed returns a usage payload (array/object), not the proxy row. + const body = await result.json(); + assert.notEqual((body as Record)?.name, "Usage Proxy"); +});