553 lines
22 KiB
TypeScript
553 lines
22 KiB
TypeScript
/**
|
|
* 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<unknown> {
|
|
return res.json();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: assert common error shape { error: { message, type }, requestId }
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function assertErrorShape(body: unknown): void {
|
|
const b = body as Record<string, unknown>;
|
|
assert.ok("error" in b, "Error response must have 'error' field");
|
|
const err = b.error as Record<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
|
|
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<string, unknown>).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<string, unknown>;
|
|
const err = b.error as Record<string, unknown>;
|
|
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<string, unknown>;
|
|
const err = b.error as Record<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>).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<string, unknown>;
|
|
|
|
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(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<string, unknown>).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<string, unknown>).error as Record<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>).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<string, unknown>;
|
|
const b2 = (await r2.json()) as Record<string, unknown>;
|
|
|
|
// 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<string, unknown>;
|
|
const b2 = (await r2.json()) as Record<string, unknown>;
|
|
|
|
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 });
|
|
});
|