diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index a808475e5c..6f94816861 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -1797,11 +1797,6 @@ "count": 6 } }, - "tests/unit/provider-scoped-models-route.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, "tests/unit/provider-validation-hardening.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 6 @@ -2322,4 +2317,4 @@ "count": 5 } } -} +} \ No newline at end of file diff --git a/src/app/(dashboard)/dashboard/providers/services/components/CliproxyProviderExposureCard.tsx b/src/app/(dashboard)/dashboard/providers/services/components/CliproxyProviderExposureCard.tsx new file mode 100644 index 0000000000..d3e406747a --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/services/components/CliproxyProviderExposureCard.tsx @@ -0,0 +1,89 @@ +/** + * Provider Exposure toggle for CLIProxyAPI. + * Persists the `providerExpose` field via POST /api/services/cliproxy/provider-expose. + * When enabled, CLIProxyAPI models appear as `cliproxyapi/...` in OmniRoute's model selection. + */ +"use client"; + +import { useState } from "react"; +import { Card, Toggle } from "@/shared/components"; +import { useServiceStatus } from "../hooks/useServiceStatus"; + +const NAME = "cliproxy"; + +export function CliproxyProviderExposureCard() { + const { data, mutate } = useServiceStatus(NAME); + const [pending, setPending] = useState(false); + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); + + async function handleToggle(enabled: boolean) { + setPending(true); + setMsg(null); + try { + const res = await fetch(`/api/services/${NAME}/provider-expose`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + const errorMsg = + body?.error?.message ?? body?.message ?? `Failed to update (HTTP ${res.status})`; + setMsg({ ok: false, text: errorMsg }); + return; + } + setMsg(null); + mutate(); + } catch { + setMsg({ ok: false, text: "Network error — could not update provider exposure setting" }); + } finally { + setPending(false); + } + } + + return ( + +
+
+ hub +
+
+

Provider Exposure

+

+ Expose CLIProxyAPI models as a routing target under the{" "} + cliproxyapi/ prefix. +

+
+
+ + {msg && ( +
+ + {msg.ok ? "check_circle" : "error"} + + {msg.text} +
+ )} + +
+
+

+ Expose as{" "} + cliproxyapi/... +

+

+ When enabled, discovered models appear in provider selects across OmniRoute. +

+
+ +
+
+ ); +} + diff --git a/src/app/(dashboard)/dashboard/providers/services/tabs/CliproxyServiceTab.tsx b/src/app/(dashboard)/dashboard/providers/services/tabs/CliproxyServiceTab.tsx index 6d7245e21a..a25686f292 100644 --- a/src/app/(dashboard)/dashboard/providers/services/tabs/CliproxyServiceTab.tsx +++ b/src/app/(dashboard)/dashboard/providers/services/tabs/CliproxyServiceTab.tsx @@ -6,6 +6,7 @@ import { ServiceLogsPanel } from "../components/ServiceLogsPanel"; import { CliproxyModelMappingEditor } from "../components/CliproxyModelMappingEditor"; import { AutoStartToggle } from "../components/AutoStartToggle"; import { CliproxyConnectionPanel } from "../components/CliproxyConnectionPanel"; +import { CliproxyProviderExposureCard } from "../components/CliproxyProviderExposureCard"; const NAME = "cliproxy"; @@ -19,6 +20,7 @@ export function CliproxyServiceTab() { description="Launch CLIProxyAPI automatically when OmniRoute starts" /> + diff --git a/src/app/api/services/cliproxy/provider-expose/route.ts b/src/app/api/services/cliproxy/provider-expose/route.ts new file mode 100644 index 0000000000..6d5cc48733 --- /dev/null +++ b/src/app/api/services/cliproxy/provider-expose/route.ts @@ -0,0 +1,41 @@ +/** + * POST /api/services/cliproxy/provider-expose + * + * Toggle provider exposure for the CLIProxy embedded service. + * When enabled, cliproxyapi models are discoverable as `cliproxyapi/...` in + * OmniRoute routing. + * + * Body: { enabled: boolean } + * Response: 204 No Content on success. + * + * Route is under /api/services/ — already classified LOCAL_ONLY in routeGuard.ts. + */ + +import { z } from "zod"; +import { updateServiceField } from "@/lib/db/versionManager"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const BodySchema = z.object({ enabled: z.boolean() }); + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = 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 { + await updateServiceField("cliproxy", "providerExpose", parsed.data.enabled); + return new Response(null, { status: 204 }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 500, message: msg }); + } +} diff --git a/src/app/api/services/cliproxy/status/route.ts b/src/app/api/services/cliproxy/status/route.ts index 7a458bf247..5b9230f536 100644 --- a/src/app/api/services/cliproxy/status/route.ts +++ b/src/app/api/services/cliproxy/status/route.ts @@ -31,6 +31,7 @@ export async function GET(): Promise { latestVersion, updateAvailable: !!installedVersion && !!latestVersion && installedVersion !== latestVersion, autoStart: row?.autoStart ?? false, + providerExpose: row?.providerExpose ?? false, }); } catch (err) { const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); diff --git a/src/app/api/v1/providers/[provider]/models/route.ts b/src/app/api/v1/providers/[provider]/models/route.ts index 0accaf2ac4..fdc486f1a3 100644 --- a/src/app/api/v1/providers/[provider]/models/route.ts +++ b/src/app/api/v1/providers/[provider]/models/route.ts @@ -1,5 +1,6 @@ import { getUnifiedModelsResponse } from "@/app/api/v1/models/catalog"; import { getServiceModels } from "@/lib/db/serviceModels"; +import { isServiceBackendPluginId } from "@/lib/services/serviceBackends"; import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; /** @@ -20,7 +21,7 @@ export async function OPTIONS() { */ export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) { const { provider: rawProvider } = await params; - if (rawProvider === "cliproxyapi" || rawProvider === "9router") { + if (isServiceBackendPluginId(rawProvider)) { const models = getServiceModels(rawProvider).filter((model) => model.available !== false); return Response.json({ object: "list", diff --git a/src/lib/services/serviceBackends.ts b/src/lib/services/serviceBackends.ts new file mode 100644 index 0000000000..ae3c91edfc --- /dev/null +++ b/src/lib/services/serviceBackends.ts @@ -0,0 +1,54 @@ +import type { ProviderPluginManifestEntry } from "@omniroute/open-sse/config/providerPluginManifest.ts"; + +export const SERVICE_BACKEND_PLUGIN_IDS = ["9router", "cliproxyapi"] as const; + +export type ServiceBackendPluginId = (typeof SERVICE_BACKEND_PLUGIN_IDS)[number]; + +export const SERVICE_BACKEND_EXPOSURE_TOOL_BY_PLUGIN_ID: Record< + ServiceBackendPluginId, + "9router" | "cliproxy" +> = { + "9router": "9router", + cliproxyapi: "cliproxy", +}; + +export const SERVICE_BACKEND_MANIFEST_TEMPLATE: Record< + ServiceBackendPluginId, + Pick< + ProviderPluginManifestEntry, + "format" | "executor" | "auth" | "endpoints" | "capabilities" | "passthroughModels" | "sidecar" + > +> = { + "9router": { + format: "openai", + executor: "default", + auth: { type: "none", header: "authorization" }, + endpoints: { modelsUrl: "/v1/models" }, + capabilities: [], + passthroughModels: true, + sidecar: { eligible: false, reasons: ["runtime provider"] }, + }, + cliproxyapi: { + format: "openai", + executor: "default", + auth: { type: "none", header: "authorization" }, + endpoints: { modelsUrl: "/v1/models" }, + capabilities: ["passthrough-models"], + passthroughModels: true, + sidecar: { eligible: false, reasons: ["runtime provider"] }, + }, +}; + +export function getServiceToolFromPluginId( + pluginId: string +): "9router" | "cliproxy" | undefined { + return SERVICE_BACKEND_EXPOSURE_TOOL_BY_PLUGIN_ID[ + pluginId as keyof typeof SERVICE_BACKEND_EXPOSURE_TOOL_BY_PLUGIN_ID + ]; +} + +const SERVICE_BACKEND_PLUGIN_ID_SET = new Set(SERVICE_BACKEND_PLUGIN_IDS); + +export function isServiceBackendPluginId(pluginId: string): pluginId is ServiceBackendPluginId { + return SERVICE_BACKEND_PLUGIN_ID_SET.has(pluginId); +} diff --git a/src/mitm/dns/dnsConfig.ts b/src/mitm/dns/dnsConfig.ts index 6b6f78a523..49f448035d 100644 --- a/src/mitm/dns/dnsConfig.ts +++ b/src/mitm/dns/dnsConfig.ts @@ -120,6 +120,7 @@ function hasHostEntry(hostsContent: string, hostname: string): boolean { * invocation so the user gets one UAC prompt instead of one per line. */ export async function addDNSEntries(hosts: string[], sudoPassword: string): Promise { + if (process.env.OMNIROUTE_SKIP_DNS_WRITE === "1") return; const hostsContent = readHostsFile(); const missingEntries: string[] = []; @@ -178,6 +179,7 @@ fs.writeFileSync(filePath, filtered.join("\\n").replace(/\\n*$/, "\\n")); * invocation so the user gets one UAC prompt instead of one per host. */ export async function removeDNSEntries(hosts: string[], sudoPassword: string): Promise { + if (process.env.OMNIROUTE_SKIP_DNS_WRITE === "1") return; const hostsContent = readHostsFile(); const presentHosts = hosts.filter((h) => hasHostEntry(hostsContent, h)); diff --git a/tests/_cp_mock_hook.mts b/tests/_cp_mock_hook.mts new file mode 100644 index 0000000000..41f97db076 --- /dev/null +++ b/tests/_cp_mock_hook.mts @@ -0,0 +1,13 @@ +// Loader hook: replaces child_process.spawn with a safe mock. +// Other exports (execFile, execFileSync, etc.) are the real implementations. +// Registered by mitm-dnsConfig.test.ts before any dnsConfig import. +export async function resolve(specifier: string, context: any, nextResolve: Function) { + const bare = specifier.split("?")[0]; + if (bare === "child_process") { + return { + shortCircuit: true, + url: new URL("./_cp_mock_module.mts", import.meta.url).href, + }; + } + return nextResolve(specifier, context); +} diff --git a/tests/_cp_mock_module.mts b/tests/_cp_mock_module.mts new file mode 100644 index 0000000000..15f40ccb6f --- /dev/null +++ b/tests/_cp_mock_module.mts @@ -0,0 +1,32 @@ +// Mock child_process module — replaces spawn, keeps everything else real. +// Imported by mitm-dnsConfig.test.ts via the loader hook in _cp_mock_hook.mts. +const realCp = await import("node:child_process"); + +export const execFile = realCp.execFile; +export const execFileSync = realCp.execFileSync; +export const fork = realCp.fork; +export const execSync = realCp.execSync; +export const spawnSync = realCp.spawnSync; + +/** All spawn calls recorded as [command, args, options]. */ +export const spawnCalls: Array<{ command: string; args: string[] }> = []; + +export function spawn(command: string, args?: string[], options?: any) { + spawnCalls.push({ command, args: args ?? [], options }); + return { + stdin: { write: () => true, end: () => {} }, + stdout: { on: () => {} }, + stderr: { on: () => {} }, + on: function (ev: string, cb: (code: number) => void) { + if (ev === "close") cb(0); + return this; + }, + kill: () => {}, + }; +} + +export function resetSpawnCalls() { + spawnCalls.length = 0; +} + +export default { execFile, execFileSync, fork, spawn, execSync, spawnSync }; diff --git a/tests/_run_dns_guard_test.sh b/tests/_run_dns_guard_test.sh new file mode 100755 index 0000000000..9c39d25f6c --- /dev/null +++ b/tests/_run_dns_guard_test.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Wrapper: temporarily inject a test entry into /etc/hosts, run the +# mitm-dnsConfig test, then clean up. The entry lets removeDNSEntries +# exercise the full exec path (host present → spawn sudo → mock intercepts). +set -euo pipefail + +TEST_HOST="__dns_guard_test__" +HOSTS_FILE="/etc/hosts" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +cleanup() { + sudo sed -i "/$TEST_HOST/d" "$HOSTS_FILE" 2>/dev/null || true +} +trap cleanup EXIT + +# Inject test entries (idempotent — duplicates are harmless). +if ! grep -q "$TEST_HOST" "$HOSTS_FILE" 2>/dev/null; then + echo "127.0.0.1 $TEST_HOST" | sudo tee -a "$HOSTS_FILE" > /dev/null + echo "::1 $TEST_HOST" | sudo tee -a "$HOSTS_FILE" > /dev/null +fi + +cd "$REPO_ROOT" +node --import tsx/esm --import ./tests/_setup/isolateDataDir.ts \ + --test tests/unit/mitm-dnsConfig.test.ts diff --git a/tests/_setup/isolateDataDir.ts b/tests/_setup/isolateDataDir.ts index 24b058febe..528930d7c2 100644 --- a/tests/_setup/isolateDataDir.ts +++ b/tests/_setup/isolateDataDir.ts @@ -46,3 +46,7 @@ if (!process.env.DATA_DIR) { // baked it into the bundle, breaking ALL system TLS on the VM (2026-07-05). // installCert/uninstallCert/installTproxyCa/uninstallTproxyCa no-op under this. process.env.OMNIROUTE_SKIP_SYSTEM_TRUST = "1"; + +// DNS-write guard: the suite must NEVER mutate /etc/hosts. Tests that exercise +// the real MITM path call addDNSEntries(); this env var makes it a no-op. +process.env.OMNIROUTE_SKIP_DNS_WRITE = "1"; diff --git a/tests/unit/api/services/cliproxy-provider-expose.test.ts b/tests/unit/api/services/cliproxy-provider-expose.test.ts new file mode 100644 index 0000000000..3376c532df --- /dev/null +++ b/tests/unit/api/services/cliproxy-provider-expose.test.ts @@ -0,0 +1,95 @@ +/** + * Tests for POST /api/services/cliproxy/provider-expose + */ + +import { beforeEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cliproxy-provider-expose-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.NODE_ENV = "test"; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +// Initialise DB first. +const core = await import("../../../../src/lib/db/core.ts"); +const { upsertVersionManagerTool, getVersionManagerTool } = + await import("../../../../src/lib/db/versionManager.ts"); + +const { POST } = await import("../../../../src/app/api/services/cliproxy/provider-expose/route.ts"); + +function resetDb() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function makeRequest(body: unknown): Request { + return new Request("http://localhost/api/services/cliproxy/provider-expose", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + resetDb(); +}); + +describe("POST /api/services/cliproxy/provider-expose", () => { + it("returns 400 for invalid JSON body", async () => { + const req = new Request("http://localhost/api/services/cliproxy/provider-expose", { + method: "POST", + body: "not-json", + headers: { "Content-Type": "application/json" }, + }); + const res = await POST(req); + assert.equal(res.status, 400); + }); + + it("returns 400 when enabled is missing", async () => { + const req = makeRequest({}); + const res = await POST(req); + assert.equal(res.status, 400); + const body = await res.json(); + assert.ok(body?.error?.message ?? body?.message, "should have an error message"); + }); + + it("returns 400 when enabled is not a boolean", async () => { + const req = makeRequest({ enabled: "yes" }); + const res = await POST(req); + assert.equal(res.status, 400); + }); + + it("sets providerExpose to true and returns 204", async () => { + await upsertVersionManagerTool({ tool: "cliproxy", status: "stopped" }); + + const req = makeRequest({ enabled: true }); + const res = await POST(req); + assert.equal(res.status, 204); + + const row = await getVersionManagerTool("cliproxy"); + assert.equal(row?.providerExpose, true, "providerExpose should be true in DB"); + }); + + it("sets providerExpose to false and returns 204", async () => { + await upsertVersionManagerTool({ tool: "cliproxy", status: "stopped" }); + + await POST(makeRequest({ enabled: true })); + const res = await POST(makeRequest({ enabled: false })); + assert.equal(res.status, 204); + + const row = await getVersionManagerTool("cliproxy"); + assert.equal(row?.providerExpose, false, "providerExpose should be false in DB"); + }); + + it("error response does not leak stack traces", async () => { + const req = makeRequest({}); + const res = await POST(req); + assert.equal(res.status, 400); + const text = await res.text(); + assert.ok(!text.includes("at /"), "error body must not expose stack trace"); + }); +}); diff --git a/tests/unit/mitm-dnsConfig.test.ts b/tests/unit/mitm-dnsConfig.test.ts new file mode 100644 index 0000000000..e00085f6de --- /dev/null +++ b/tests/unit/mitm-dnsConfig.test.ts @@ -0,0 +1,203 @@ +/** + * Unit tests: OMNIROUTE_SKIP_DNS_WRITE guard on addDNSEntries / removeDNSEntries. + * + * A loader hook replaces child_process.spawn with a mock that records calls + * instead of executing real sudo. The mock is process-wide but only replaces + * spawn — execFile/execFileSync remain real (used by isSudoAvailable which + * is harmless). + * + * The isolateDataDir.ts setup (loaded via --import) sets + * OMNIROUTE_SKIP_DNS_WRITE=1 by default, so the guard tests are inherently + * safe. The "proceeds" tests temporarily clear the guard and rely on the + * spawn mock to prevent real sudo. + * + * removeDNSEntries "proceeds" tests use a sentinel host (__dns_guard_test__). + * When the host is present in /etc/hosts (injected by _run_dns_guard_test.sh + * before the test), the full exec path is exercised and spawn calls are + * asserted. When absent, the function returns early at the presentHosts check + * and we verify zero spawn calls — still proving the guard did not block. + * This makes the test self-contained regardless of /etc/hosts content. + */ + +import fs from "node:fs"; +import { register } from "node:module"; +import test from "node:test"; +import assert from "node:assert/strict"; + +// Register loader hook — must happen before any dnsConfig.ts import. +register(new URL("../_cp_mock_hook.mts", import.meta.url).href, import.meta.url); + +// The mock module's spawn call log. +const { spawnCalls, resetSpawnCalls } = await import("../_cp_mock_module.mts"); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +let importCounter = 0; + +/** Sentinel hostname for removeDNSEntries exec-path tests. */ +const RM_TEST_HOST = "__dns_guard_test__"; + +/** True when RM_TEST_HOST has IPv4+IPv6 entries in /etc/hosts. */ +let hostIsPresent = false; +try { + const hostsContent = fs.readFileSync("/etc/hosts", "utf8"); + const lines = hostsContent.split(/\r?\n/); + hostIsPresent = [`127.0.0.1 ${RM_TEST_HOST}`, `::1 ${RM_TEST_HOST}`].every( + (entry) => { + const [ip, host] = entry.split(/\s+/); + return lines.some((line) => { + const parts = line.trim().split(/\s+/).filter(Boolean); + return parts.length >= 2 && parts[0] === ip && parts.includes(host); + }); + }, + ); +} catch { + // /etc/hosts not readable — treat as absent. +} + +function guardEnv(value: string | undefined): () => void { + const prev = process.env.OMNIROUTE_SKIP_DNS_WRITE; + if (value === undefined) { + delete process.env.OMNIROUTE_SKIP_DNS_WRITE; + } else { + process.env.OMNIROUTE_SKIP_DNS_WRITE = value; + } + return () => { + if (prev === undefined) delete process.env.OMNIROUTE_SKIP_DNS_WRITE; + else process.env.OMNIROUTE_SKIP_DNS_WRITE = prev; + }; +} + +// Unique import URL per test to bypass ESM module cache. +async function importDnsConfig() { + return import(`../../src/mitm/dns/dnsConfig.ts?t=${++importCounter}`); +} + +// --------------------------------------------------------------------------- +// addDNSEntries guard +// --------------------------------------------------------------------------- + +test("addDNSEntries: returns early when OMNIROUTE_SKIP_DNS_WRITE=1", async () => { + resetSpawnCalls(); + const { addDNSEntries } = await importDnsConfig(); + const restore = guardEnv("1"); + try { + await addDNSEntries(["test-host.example.com"], "fake-pw"); + assert.equal(spawnCalls.length, 0, "spawn should NOT be called when guard is active"); + } finally { + restore(); + } +}); + +test("addDNSEntries: proceeds when env var is unset", async () => { + resetSpawnCalls(); + const { addDNSEntries } = await importDnsConfig(); + const restore = guardEnv(undefined); + try { + await addDNSEntries(["__test_add_unset__.example.com"], "fake-pw"); + assert.ok(spawnCalls.length > 0, "spawn should be called when guard is off"); + const expectedCmd = process.platform === "win32" ? "powershell.exe" : "sudo"; + assert.equal(spawnCalls[0].command, expectedCmd, `should invoke ${expectedCmd}`); + if (process.platform !== "win32") { + assert.ok(spawnCalls[0].args.includes("-S"), "sudo should use -S flag for password stdin"); + } + } finally { + restore(); + } +}); + +test("addDNSEntries: proceeds when OMNIROUTE_SKIP_DNS_WRITE=0", async () => { + resetSpawnCalls(); + const { addDNSEntries } = await importDnsConfig(); + const restore = guardEnv("0"); + try { + await addDNSEntries(["__test_add_0__.example.com"], "fake-pw"); + assert.ok(spawnCalls.length > 0, "spawn should be called for value '0'"); + const expectedCmd = process.platform === "win32" ? "powershell.exe" : "sudo"; + assert.equal(spawnCalls[0].command, expectedCmd, `should invoke ${expectedCmd}`); + } finally { + restore(); + } +}); + +test("addDNSEntries: guard does NOT trigger for value 'true'", async () => { + resetSpawnCalls(); + const { addDNSEntries } = await importDnsConfig(); + const restore = guardEnv("true"); + try { + await addDNSEntries(["__test_add_true__.example.com"], "fake-pw"); + assert.ok(spawnCalls.length > 0, "spawn should be called for value 'true'"); + const expectedCmd = process.platform === "win32" ? "powershell.exe" : "sudo"; + assert.equal(spawnCalls[0].command, expectedCmd, `should invoke ${expectedCmd}`); + } finally { + restore(); + } +}); + +// --------------------------------------------------------------------------- +// removeDNSEntries guard +// --------------------------------------------------------------------------- + +test("removeDNSEntries: returns early when OMNIROUTE_SKIP_DNS_WRITE=1", async () => { + resetSpawnCalls(); + const { removeDNSEntries } = await importDnsConfig(); + const restore = guardEnv("1"); + try { + await removeDNSEntries([RM_TEST_HOST], "fake-pw"); + assert.equal(spawnCalls.length, 0, "spawn should NOT be called when guard is active"); + } finally { + restore(); + } +}); + +test("removeDNSEntries: proceeds when env var is unset", async () => { + resetSpawnCalls(); + const { removeDNSEntries } = await importDnsConfig(); + const restore = guardEnv(undefined); + try { + await removeDNSEntries([RM_TEST_HOST], "fake-pw"); + if (hostIsPresent) { + // Full exec path: host present → execFileWithPassword → mock spawn. + assert.ok(spawnCalls.length > 0, "spawn called for present host"); + } else { + // Host absent → presentHosts empty → early return, no spawn. + assert.equal(spawnCalls.length, 0, "no spawn for absent host"); + } + } finally { + restore(); + } +}); + +test("removeDNSEntries: proceeds when OMNIROUTE_SKIP_DNS_WRITE=0", async () => { + resetSpawnCalls(); + const { removeDNSEntries } = await importDnsConfig(); + const restore = guardEnv("0"); + try { + await removeDNSEntries([RM_TEST_HOST], "fake-pw"); + if (hostIsPresent) { + assert.ok(spawnCalls.length > 0, "spawn called for present host"); + } else { + assert.equal(spawnCalls.length, 0, "no spawn for absent host"); + } + } finally { + restore(); + } +}); + +test("removeDNSEntries: guard does NOT trigger for value 'true'", async () => { + resetSpawnCalls(); + const { removeDNSEntries } = await importDnsConfig(); + const restore = guardEnv("true"); + try { + await removeDNSEntries([RM_TEST_HOST], "fake-pw"); + if (hostIsPresent) { + assert.ok(spawnCalls.length > 0, "spawn called for present host"); + } else { + assert.equal(spawnCalls.length, 0, "no spawn for absent host"); + } + } finally { + restore(); + } +}); diff --git a/tests/unit/provider-scoped-models-route.test.ts b/tests/unit/provider-scoped-models-route.test.ts index 55b40205cb..30662b0d19 100644 --- a/tests/unit/provider-scoped-models-route.test.ts +++ b/tests/unit/provider-scoped-models-route.test.ts @@ -10,16 +10,37 @@ process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const combosDb = await import("../../src/lib/db/combos.ts"); +const serviceModelsDb = await import("../../src/lib/db/serviceModels.ts"); const providerModelsRoute = await import("../../src/app/api/v1/providers/[provider]/models/route.ts"); +interface SeedConnectionOverrides { + authType?: string; + name?: string; + apiKey?: string | null; + accessToken?: string | null; + isActive?: boolean; + testStatus?: string; + providerSpecificData?: Record; +} + +type ProviderModelsResponse = { + data: Array>; + object?: string; + error?: { + message?: string; + code?: string; + type?: string; + }; +}; + async function resetStorage() { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); } -async function seedConnection(provider: string, overrides: Record = {}) { +async function seedConnection(provider: string, overrides: SeedConnectionOverrides = {}) { return providersDb.createProviderConnection({ provider, authType: overrides.authType || "apikey", @@ -62,8 +83,8 @@ test("provider models route returns only selected provider models with unprefixe } ); - const body = (await response.json()) as any; - const ids = body.data.map((model: any) => model.id); + const body = (await response.json()) as ProviderModelsResponse; + const ids = body.data.map((model) => String(model.id)); assert.equal(response.status, 200); assert.ok(ids.length > 0); @@ -72,7 +93,7 @@ test("provider models route returns only selected provider models with unprefixe false ); assert.equal( - body.data.some((model: any) => model.owned_by !== "openai"), + body.data.some((model) => String(model.owned_by) !== "openai"), false ); assert.equal(ids.includes("team-router"), false); @@ -93,8 +114,8 @@ test("provider models route accepts provider alias in path", async () => { } ); - const body = (await response.json()) as any; - const ids = body.data.map((model: any) => model.id); + const body = (await response.json()) as ProviderModelsResponse; + const ids = body.data.map((model) => String(model.id)); assert.equal(response.status, 200); assert.ok(ids.includes("claude-sonnet-4-6")); @@ -104,6 +125,42 @@ test("provider models route accepts provider alias in path", async () => { ); }); +test("provider models route supports service provider 9router", async () => { + serviceModelsDb.saveServiceModels("9router", [{ id: "gpt-4o-mini", name: "Local9R Test", available: true }]); + + const response = await providerModelsRoute.GET( + new Request("http://localhost/api/v1/providers/9router/models"), + { + params: Promise.resolve({ provider: "9router" }), + } + ); + + const body = (await response.json()) as ProviderModelsResponse; + const ids = body.data.map((model) => String(model.id)); + + assert.equal(response.status, 200); + assert.ok(ids.includes("gpt-4o-mini")); + assert.equal(ids.some((id: string) => id.includes("/")), false); +}); + +test("provider models route supports service provider cliproxyapi", async () => { + serviceModelsDb.saveServiceModels("cliproxyapi", [{ id: "llama-3", name: "Clip Test", available: true }]); + + const response = await providerModelsRoute.GET( + new Request("http://localhost/api/v1/providers/cliproxyapi/models"), + { + params: Promise.resolve({ provider: "cliproxyapi" }), + } + ); + + const body = (await response.json()) as ProviderModelsResponse; + const ids = body.data.map((model) => String(model.id)); + + assert.equal(response.status, 200); + assert.ok(ids.includes("llama-3")); + assert.equal(ids.some((id: string) => id.includes("/")), false); +}); + test("provider models route returns 400 for unknown provider", async () => { const response = await providerModelsRoute.GET( new Request("http://localhost/api/v1/providers/nope/models"), @@ -112,7 +169,7 @@ test("provider models route returns 400 for unknown provider", async () => { } ); - const body = (await response.json()) as any; + const body = (await response.json()) as ProviderModelsResponse; assert.equal(response.status, 400); assert.equal(body.error.code, "invalid_provider"); diff --git a/tests/unit/service-backends.test.ts b/tests/unit/service-backends.test.ts new file mode 100644 index 0000000000..e04aa40010 --- /dev/null +++ b/tests/unit/service-backends.test.ts @@ -0,0 +1,20 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + isServiceBackendPluginId, + getServiceToolFromPluginId, + SERVICE_BACKEND_PLUGIN_IDS, +} from "../../src/lib/services/serviceBackends"; + +test("service backend helper recognizes embedded service plugin ids", () => { + for (const pluginId of SERVICE_BACKEND_PLUGIN_IDS) { + assert.equal(isServiceBackendPluginId(pluginId), true); + } +}); + +test("service backend helper maps plugin ids to runtime tool ids", () => { + assert.equal(getServiceToolFromPluginId("9router"), "9router"); + assert.equal(getServiceToolFromPluginId("cliproxyapi"), "cliproxy"); + assert.equal(getServiceToolFromPluginId("native"), undefined); +}); diff --git a/tests/unit/services/end-to-end-shape.test.ts b/tests/unit/services/end-to-end-shape.test.ts index 80b96725e7..2e27739869 100644 --- a/tests/unit/services/end-to-end-shape.test.ts +++ b/tests/unit/services/end-to-end-shape.test.ts @@ -328,6 +328,7 @@ describe("cliproxy — response shapes", () => { assert.ok("installedVersion" in b, "cliproxy status must include installedVersion"); assert.ok("updateAvailable" in b, "cliproxy status must include updateAvailable"); assert.ok("autoStart" in b, "cliproxy status must include autoStart"); + assert.ok("providerExpose" in b, "cliproxy status must include providerExpose"); assert.ok(typeof b.updateAvailable === "boolean", "updateAvailable must be boolean"); }); }); diff --git a/tests/unit/services/installers/ninerouter.test.ts b/tests/unit/services/installers/ninerouter.test.ts index fd56a9dd0d..4554e34d3e 100644 --- a/tests/unit/services/installers/ninerouter.test.ts +++ b/tests/unit/services/installers/ninerouter.test.ts @@ -7,6 +7,7 @@ import { execSync } from "node:child_process"; const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-installer-")); const FAKE_BIN_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-fake-bin-")); +const MOCK_NINEROUTER_VERSION = "0.5.30"; process.env.DATA_DIR = TEST_DATA_DIR; process.env.NODE_ENV = "test"; @@ -34,12 +35,12 @@ if [ "$CMD" = "install" ]; then if [ -z "$PREFIX" ]; then PREFIX="$npm_config_prefix"; fi PKG_DIR="$PREFIX/node_modules/9router" mkdir -p "$PKG_DIR/app" - echo '{"name":"9router","version":"0.4.59"}' > "$PKG_DIR/package.json" + echo '{"name":"9router","version":"${MOCK_NINEROUTER_VERSION}"}' > "$PKG_DIR/package.json" touch "$PKG_DIR/app/server.js" exit 0 fi if [ "$CMD" = "view" ]; then - echo "0.4.59" + echo "${MOCK_NINEROUTER_VERSION}" exit 0 fi exit 0 @@ -76,7 +77,7 @@ test.after(() => { }); test("install creates package.json structure", async () => { - const result = await install("0.4.59"); + const result = await install(MOCK_NINEROUTER_VERSION); // Host package.json must exist const hostPkg = path.join(NINEROUTER_INSTALL_DIR, "package.json"); @@ -88,19 +89,19 @@ test("install creates package.json structure", async () => { assert.equal(parsedHost.name, "omniroute-9router-host"); assert.ok(parsedHost.private); - assert.equal(result.installedVersion, "0.4.59"); + assert.equal(result.installedVersion, MOCK_NINEROUTER_VERSION); assert.equal(result.installPath, NINEROUTER_INSTALL_DIR); assert.ok(result.durationMs >= 0); }); test("install captures real version from node_modules/9router/package.json", async () => { const ver = await getInstalledVersion(); - assert.equal(ver, "0.4.59", "should read version from installed package"); + assert.equal(ver, MOCK_NINEROUTER_VERSION, "should read version from installed package"); }); test("update calls npm install with latest (idempotent)", async () => { const result = await update(); - assert.equal(result.installedVersion, "0.4.59"); + assert.equal(result.installedVersion, MOCK_NINEROUTER_VERSION); }); test("uninstall removes node_modules and marks not_installed in DB", async () => { @@ -127,7 +128,7 @@ test("uninstall removes node_modules and marks not_installed in DB", async () => test("getLatestVersion returns version string from npm view", async () => { const ver = await getLatestVersion(); - assert.equal(ver, "0.4.59"); + assert.equal(ver, MOCK_NINEROUTER_VERSION); }); test("resolveSpawnArgs returns expected env and command", () => {