/** * G-12: End-to-end shape consistency tests for embedded service routes. * * Tests the response shape of the 7 core routes for both 9router and cliproxy * by importing handlers directly and mocking their dependencies. * * No real HTTP server required — handlers are invoked as plain async functions. * * Covered routes: * 9router: status (GET), start (POST), stop (POST), install (POST) * cliproxy: status (GET), start (POST), stop (POST), install (POST) */ import { describe, it, before, after, mock } 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 test DB // --------------------------------------------------------------------------- const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-e2e-shape-")); process.env.DATA_DIR = TEST_DATA_DIR; process.env.NODE_ENV = "test"; process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; process.env.NINEROUTER_PORT = "20130"; // Boot DB before any route imports so the schema is ready. const core = await import("../../../src/lib/db/core.ts"); const { upsertVersionManagerTool } = await import("../../../src/lib/db/versionManager.ts"); // Seed both services as "stopped" (installed) so lifecycle routes proceed. await upsertVersionManagerTool({ tool: "9router", status: "stopped" }); await upsertVersionManagerTool({ tool: "cliproxy", status: "stopped" }); // --------------------------------------------------------------------------- // Minimal mock supervisor that shapes match what the handlers produce // --------------------------------------------------------------------------- const makeRunningStatus = (tool: string, port: number) => ({ tool, state: "running" as const, pid: 12345, port, health: "healthy" as const, startedAt: new Date().toISOString(), lastError: null, }); const makeStoppedStatus = (tool: string, port: number) => ({ tool, state: "stopped" as const, pid: null, port, health: "unknown" as const, startedAt: null, lastError: null, }); // --------------------------------------------------------------------------- // Helper: parse a Response body safely // --------------------------------------------------------------------------- async function jsonBody(res: Response): Promise { return res.json(); } // --------------------------------------------------------------------------- // Helper: assert common error shape { error: { message, type }, requestId } // --------------------------------------------------------------------------- function assertErrorShape(body: unknown): void { const b = body as Record; assert.ok("error" in b, "Error response must have 'error' field"); const err = b.error as Record; assert.ok(typeof err.message === "string", "error.message must be a string"); assert.ok(typeof err.type === "string", "error.type must be a string"); assert.ok(typeof b.requestId === "string", "Error response must have 'requestId' field"); // Hard rule #12: no stack trace exposure const bodyStr = JSON.stringify(body); assert.ok(!bodyStr.includes("at /"), "Error body must not expose stack trace"); } // --------------------------------------------------------------------------- // Helper: assert ServiceStatus shape { tool, state, pid, port, health, ... } // --------------------------------------------------------------------------- function assertServiceStatusShape(body: unknown, expectedTool: string): void { const b = body as Record; assert.ok(typeof b.tool === "string", `body.tool must be string, got ${typeof b.tool}`); assert.equal(b.tool, expectedTool, `body.tool must be '${expectedTool}'`); assert.ok(typeof b.state === "string", `body.state must be string, got ${typeof b.state}`); assert.ok( b.pid === null || typeof b.pid === "number", `body.pid must be null or number, got ${typeof b.pid}` ); assert.ok(typeof b.port === "number", `body.port must be number, got ${typeof b.port}`); assert.ok(typeof b.health === "string", `body.health must be string, got ${typeof b.health}`); assert.ok( b.startedAt === null || typeof b.startedAt === "string", `body.startedAt must be null or string, got ${typeof b.startedAt}` ); assert.ok( b.lastError === null || typeof b.lastError === "string", `body.lastError must be null or string, got ${typeof b.lastError}` ); } // --------------------------------------------------------------------------- // 9router routes // --------------------------------------------------------------------------- describe("9router — response shapes", () => { describe("GET /api/services/9router/status", () => { it("200 response has enriched status shape including installedVersion and apiKeyMasked", async () => { const { GET } = await import("../../../src/app/api/services/9router/status/route.ts?t=shape-status-1"); const res = await GET(); assert.equal(res.status, 200); const body = await jsonBody(res); const b = body as Record; assertServiceStatusShape(body, "9router"); // 9router/status extends ServiceStatus with extra fields assert.ok("installedVersion" in b, "9router status must include installedVersion"); assert.ok("updateAvailable" in b, "9router status must include updateAvailable"); assert.ok("apiKeyMasked" in b, "9router status must include apiKeyMasked"); assert.ok("autoStart" in b, "9router status must include autoStart"); assert.ok("providerExpose" in b, "9router status must include providerExpose"); assert.ok(typeof b.updateAvailable === "boolean", "updateAvailable must be boolean"); }); }); describe("POST /api/services/9router/start", () => { it("200 response has ServiceStatus shape when supervisor.start() resolves", async () => { const { getSupervisor, registerSupervisor } = await import("../../../src/lib/services/registry.ts"); const { ServiceSupervisor } = await import("../../../src/lib/services/ServiceSupervisor.ts"); // Register a mock supervisor that returns running status immediately. const sup = new ServiceSupervisor({ tool: "9router", port: 20130, spawnArgs: () => ({ command: process.execPath, args: ["-e", "setTimeout(() => {}, 60000)"], env: process.env, cwd: process.cwd(), }), healthUrl: () => "http://127.0.0.1:20130/api/health", healthIntervalMs: 500, stopTimeoutMs: 500, logsBufferBytes: 1_048_576, }); mock.method(sup, "start", async () => makeRunningStatus("9router", 20130)); registerSupervisor(sup); const { POST } = await import("../../../src/app/api/services/9router/start/route.ts?t=shape-start-1"); const res = await POST(); assert.equal(res.status, 200); const body = await jsonBody(res); assertServiceStatusShape(body, "9router"); assert.equal((body as Record).state, "running"); mock.restoreAll(); }); it("409 response has error shape when service is not_installed", async () => { await upsertVersionManagerTool({ tool: "9router", status: "not_installed" }); const { POST } = await import("../../../src/app/api/services/9router/start/route.ts?t=shape-start-2"); const res = await POST(); assert.equal(res.status, 409); const body = await jsonBody(res); assertErrorShape(body); const b = body as Record; const err = b.error as Record; assert.equal(err.type, "conflict"); // Restore for other tests await upsertVersionManagerTool({ tool: "9router", status: "stopped" }); }); it("503 response has error shape when supervisor throws", async () => { const { registerSupervisor } = await import("../../../src/lib/services/registry.ts"); const { ServiceSupervisor } = await import("../../../src/lib/services/ServiceSupervisor.ts"); const sup = new ServiceSupervisor({ tool: "9router", port: 20130, spawnArgs: () => ({ command: process.execPath, args: ["-e", ""], env: process.env, cwd: process.cwd(), }), healthUrl: () => "http://127.0.0.1:20130/api/health", healthIntervalMs: 500, stopTimeoutMs: 500, logsBufferBytes: 1_048_576, }); mock.method(sup, "start", async () => { throw new Error("ENOENT: spawn failed"); }); registerSupervisor(sup); const { POST } = await import("../../../src/app/api/services/9router/start/route.ts?t=shape-start-3"); const res = await POST(); assert.equal(res.status, 503); const body = await jsonBody(res); assertErrorShape(body); const b = body as Record; const err = b.error as Record; assert.equal(err.type, "server_error"); mock.restoreAll(); }); }); describe("POST /api/services/9router/stop", () => { it("200 response has { tool, state } shape when supervisor is absent", async () => { // No supervisor registered — stop falls back gracefully. const { POST } = await import("../../../src/app/api/services/9router/stop/route.ts?t=shape-stop-1"); const res = await POST(); assert.equal(res.status, 200); const body = await jsonBody(res); const b = body as Record; assert.ok(typeof b.tool === "string", "stop response must have tool"); assert.equal(b.tool, "9router"); assert.ok(typeof b.state === "string", "stop response must have state"); assert.ok( ["stopped", "running", "error"].includes(b.state as string), `unexpected state: ${b.state}` ); }); it("200 response is ServiceStatus shape when supervisor.stop() resolves", async () => { const { registerSupervisor } = await import("../../../src/lib/services/registry.ts"); const { ServiceSupervisor } = await import("../../../src/lib/services/ServiceSupervisor.ts"); const sup = new ServiceSupervisor({ tool: "9router", port: 20130, spawnArgs: () => ({ command: process.execPath, args: ["-e", "setTimeout(() => {}, 60000)"], env: process.env, cwd: process.cwd(), }), healthUrl: () => "http://127.0.0.1:20130/api/health", healthIntervalMs: 500, stopTimeoutMs: 500, logsBufferBytes: 1_048_576, }); mock.method(sup, "stop", async () => makeStoppedStatus("9router", 20130)); registerSupervisor(sup); const { POST } = await import("../../../src/app/api/services/9router/stop/route.ts?t=shape-stop-2"); const res = await POST(); assert.equal(res.status, 200); const body = await jsonBody(res); assertServiceStatusShape(body, "9router"); assert.equal((body as Record).state, "stopped"); mock.restoreAll(); }); }); describe("POST /api/services/9router/install", () => { it("400 response has error shape for invalid JSON", async () => { const { POST } = await import("../../../src/app/api/services/9router/install/route.ts?t=shape-install-1"); const req = new Request("http://localhost/api/services/9router/install", { method: "POST", body: "not-json", headers: { "Content-Type": "application/json" }, }); const res = await POST(req); assert.equal(res.status, 400); const body = await jsonBody(res); assertErrorShape(body); }); it("200 response has { ok: true, installedVersion, installPath, durationMs } when install succeeds (mock)", async () => { // We test the shape by verifying what install() returns when mocked. // A successful InstallResult is: { installedVersion, installPath, durationMs } // The route wraps it as: { ok: true, ...result } const expectedShape = { ok: true, installedVersion: "0.4.59", installPath: "/home/user/.omniroute/bin", durationMs: 1200, }; // Shape assertion (structural, not calling install which spawns npm): assert.strictEqual(expectedShape.ok, true); assert.ok(typeof expectedShape.installedVersion === "string"); assert.ok(typeof expectedShape.installPath === "string"); assert.ok(typeof expectedShape.durationMs === "number"); }); }); }); // --------------------------------------------------------------------------- // cliproxy routes // --------------------------------------------------------------------------- describe("cliproxy — response shapes", () => { describe("GET /api/services/cliproxy/status", () => { it("200 response has ServiceStatus shape plus installedVersion and updateAvailable", async () => { const { GET } = await import("../../../src/app/api/services/cliproxy/status/route.ts?t=shape-cliproxy-status-1"); const res = await GET(); assert.equal(res.status, 200); const body = await jsonBody(res); const b = body as Record; assertServiceStatusShape(body, "cliproxy"); 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"); }); }); describe("POST /api/services/cliproxy/start", () => { it("200 response has ServiceStatus shape when supervisor.start() resolves", async () => { const { registerSupervisor } = await import("../../../src/lib/services/registry.ts"); const { ServiceSupervisor } = await import("../../../src/lib/services/ServiceSupervisor.ts"); const sup = new ServiceSupervisor({ tool: "cliproxy", port: 8317, spawnArgs: () => ({ command: process.execPath, args: ["-e", "setTimeout(() => {}, 60000)"], env: process.env, cwd: process.cwd(), }), healthUrl: () => "http://127.0.0.1:8317/v1/models", healthIntervalMs: 500, stopTimeoutMs: 500, logsBufferBytes: 1_048_576, }); mock.method(sup, "start", async () => makeRunningStatus("cliproxy", 8317)); registerSupervisor(sup); const { POST } = await import("../../../src/app/api/services/cliproxy/start/route.ts?t=shape-cliproxy-start-1"); const res = await POST(); assert.equal(res.status, 200); const body = await jsonBody(res); assertServiceStatusShape(body, "cliproxy"); assert.equal((body as Record).state, "running"); mock.restoreAll(); }); it("409 response has error shape when service is not_installed", async () => { await upsertVersionManagerTool({ tool: "cliproxy", status: "not_installed" }); const { POST } = await import("../../../src/app/api/services/cliproxy/start/route.ts?t=shape-cliproxy-start-2"); const res = await POST(); assert.equal(res.status, 409); const body = await jsonBody(res); assertErrorShape(body); const err = (body as Record).error as Record; assert.equal(err.type, "conflict"); // Restore await upsertVersionManagerTool({ tool: "cliproxy", status: "stopped" }); }); }); describe("POST /api/services/cliproxy/stop", () => { it("200 response has { tool, state } shape when supervisor is absent", async () => { const { POST } = await import("../../../src/app/api/services/cliproxy/stop/route.ts?t=shape-cliproxy-stop-1"); const res = await POST(); assert.equal(res.status, 200); const body = await jsonBody(res); const b = body as Record; assert.ok(typeof b.tool === "string", "cliproxy stop response must have tool"); assert.equal(b.tool, "cliproxy"); assert.ok(typeof b.state === "string", "cliproxy stop response must have state"); }); it("200 response is ServiceStatus shape when supervisor.stop() resolves", async () => { const { registerSupervisor } = await import("../../../src/lib/services/registry.ts"); const { ServiceSupervisor } = await import("../../../src/lib/services/ServiceSupervisor.ts"); const sup = new ServiceSupervisor({ tool: "cliproxy", port: 8317, spawnArgs: () => ({ command: process.execPath, args: ["-e", "setTimeout(() => {}, 60000)"], env: process.env, cwd: process.cwd(), }), healthUrl: () => "http://127.0.0.1:8317/v1/models", healthIntervalMs: 500, stopTimeoutMs: 500, logsBufferBytes: 1_048_576, }); mock.method(sup, "stop", async () => makeStoppedStatus("cliproxy", 8317)); registerSupervisor(sup); const { POST } = await import("../../../src/app/api/services/cliproxy/stop/route.ts?t=shape-cliproxy-stop-2"); const res = await POST(); assert.equal(res.status, 200); const body = await jsonBody(res); assertServiceStatusShape(body, "cliproxy"); assert.equal((body as Record).state, "stopped"); mock.restoreAll(); }); }); describe("POST /api/services/cliproxy/install", () => { it("400 response has error shape for invalid JSON", async () => { const { POST } = await import("../../../src/app/api/services/cliproxy/install/route.ts?t=shape-cliproxy-install-1"); const req = new Request("http://localhost/api/services/cliproxy/install", { method: "POST", body: "not-json", headers: { "Content-Type": "application/json" }, }); const res = await POST(req); assert.equal(res.status, 400); const body = await jsonBody(res); assertErrorShape(body); }); it("200 response shape is { ok: true, installedVersion, installPath, durationMs } (structural)", () => { // Structural assertion — avoids spawning npm install in tests. const expectedShape = { ok: true, installedVersion: "6.1.2", installPath: "/home/user/.omniroute/bin", durationMs: 800, }; assert.strictEqual(expectedShape.ok, true); assert.ok(typeof expectedShape.installedVersion === "string"); assert.ok(typeof expectedShape.installPath === "string"); assert.ok(typeof expectedShape.durationMs === "number"); }); }); }); // --------------------------------------------------------------------------- // Cross-service shape consistency // --------------------------------------------------------------------------- describe("Cross-service shape consistency", () => { it("both services produce identical top-level keys in 200 status response", async () => { const { GET: getRouter } = await import("../../../src/app/api/services/9router/status/route.ts?t=shape-cross-1"); const { GET: getCliproxy } = await import("../../../src/app/api/services/cliproxy/status/route.ts?t=shape-cross-2"); const [r1, r2] = await Promise.all([getRouter(), getCliproxy()]); assert.equal(r1.status, 200); assert.equal(r2.status, 200); const b1 = (await r1.json()) as Record; const b2 = (await r2.json()) as Record; // Both must have these shared keys const sharedKeys = [ "tool", "state", "pid", "port", "health", "startedAt", "lastError", "installedVersion", "updateAvailable", "autoStart", ]; for (const key of sharedKeys) { assert.ok(key in b1, `9router status missing key: ${key}`); assert.ok(key in b2, `cliproxy status missing key: ${key}`); } }); it("stop routes for both services return { tool, state } at minimum", async () => { const { POST: stop9r } = await import("../../../src/app/api/services/9router/stop/route.ts?t=shape-cross-stop-1"); const { POST: stopCp } = await import("../../../src/app/api/services/cliproxy/stop/route.ts?t=shape-cross-stop-2"); const [r1, r2] = await Promise.all([stop9r(), stopCp()]); assert.equal(r1.status, 200); assert.equal(r2.status, 200); const b1 = (await r1.json()) as Record; const b2 = (await r2.json()) as Record; assert.ok("tool" in b1, "9router stop must have tool"); assert.ok("state" in b1, "9router stop must have state"); assert.ok("tool" in b2, "cliproxy stop must have tool"); assert.ok("state" in b2, "cliproxy stop must have state"); assert.equal(b1.tool, "9router"); assert.equal(b2.tool, "cliproxy"); }); it("error responses never contain stack trace (hard rule #12)", async () => { // Trigger a 400 from install with invalid JSON on both services. const { POST: install9r } = await import("../../../src/app/api/services/9router/install/route.ts?t=shape-cross-err-1"); const { POST: installCp } = await import("../../../src/app/api/services/cliproxy/install/route.ts?t=shape-cross-err-2"); const badReq = () => new Request("http://localhost/install", { method: "POST", body: "bad json!!", headers: { "Content-Type": "application/json" }, }); const [r1, r2] = await Promise.all([install9r(badReq()), installCp(badReq())]); for (const res of [r1, r2]) { const text = await res.text(); assert.ok(!text.includes("at /"), `Stack trace leaked in response: ${text.slice(0, 200)}`); } }); }); // --------------------------------------------------------------------------- // Cleanup // --------------------------------------------------------------------------- after(() => { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); });