Files
2026-07-13 13:39:12 +08:00

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 });
});