/** * G-12: Full lifecycle integration test for 9router and CLIProxyAPI. * * This test exercises the real install → start → /v1/models → chat → stop → * uninstall flow. It is GATED behind the environment flag: * * RUN_SERVICES_INT=1 node --import tsx/esm --test tests/integration/services/full-lifecycle.int.test.ts * * All sub-tests skip automatically when `RUN_SERVICES_INT !== "1"`. * This prevents the test from running in CI (slow, network-dependent) while * keeping the documentation of what the full flow looks like. * * Prerequisites when running for real: * - npm is in PATH * - Network access to registry.npmjs.org * - Ports 20130 (9router), 8317 (cliproxy), and 8080 (bifrost) are available * - DATA_DIR is writable * - Bifrost lazily downloads its Go binary from downloads.getmaxim.ai on first * start, so the bifrost block additionally needs network access to that host. */ import { describe, it } from "node:test"; import assert from "node:assert/strict"; const ENABLED = process.env.RUN_SERVICES_INT === "1"; const SKIP_REASON = "Set RUN_SERVICES_INT=1 to run full lifecycle integration tests"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Skip if the integration gate is not set. * Returns true when the test should be skipped (caller must return immediately). */ function maybeSkip(t: { skip: (reason?: string) => void }): boolean { if (!ENABLED) { t.skip(SKIP_REASON); return true; } return false; } const BASE_URL = process.env.OMNIROUTE_TEST_URL ?? "http://localhost:20128"; async function apiPost(path: string, body?: unknown): Promise<{ status: number; body: unknown }> { const res = await fetch(`${BASE_URL}${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: body !== undefined ? JSON.stringify(body) : undefined, }); const text = await res.text(); let parsed: unknown; try { parsed = JSON.parse(text); } catch { parsed = text; } return { status: res.status, body: parsed }; } async function apiGet(path: string): Promise<{ status: number; body: unknown }> { const res = await fetch(`${BASE_URL}${path}`, { method: "GET", headers: { "Content-Type": "application/json" }, }); const text = await res.text(); let parsed: unknown; try { parsed = JSON.parse(text); } catch { parsed = text; } return { status: res.status, body: parsed }; } /** Poll status until the state matches or timeout is exceeded. */ async function waitForState( statusPath: string, targetState: string, timeoutMs = 30_000, intervalMs = 1_000 ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const { body } = await apiGet(statusPath); const b = body as Record; if (b.state === targetState) return body; await new Promise((r) => setTimeout(r, intervalMs)); } throw new Error(`Timed out waiting for state=${targetState} on ${statusPath}`); } // --------------------------------------------------------------------------- // 9router lifecycle // --------------------------------------------------------------------------- describe("9router — full lifecycle (opt-in, RUN_SERVICES_INT=1)", () => { it("STEP 1: install 9router (latest)", async (t) => { if (maybeSkip(t)) return; const { status, body } = await apiPost("/api/services/9router/install", { version: "latest", }); assert.ok( status === 200, `Expected 200 from install, got ${status}: ${JSON.stringify(body).slice(0, 300)}` ); const b = body as Record; assert.ok(b.ok === true, "install response must have ok:true"); assert.ok( typeof b.installedVersion === "string", "install response must have installedVersion" ); assert.ok(typeof b.durationMs === "number", "install response must have durationMs"); }); it("STEP 2: verify status is stopped after install", async (t) => { if (maybeSkip(t)) return; const { status, body } = await apiGet("/api/services/9router/status"); assert.equal(status, 200); const b = body as Record; assert.ok( ["stopped", "not_installed"].includes(b.state as string) === false ? b.state === "stopped" : true, `Expected stopped state after install, got: ${b.state}` ); // installedVersion should now be set assert.ok( typeof b.installedVersion === "string", "installedVersion should be set after install" ); }); it("STEP 3: start 9router", async (t) => { if (maybeSkip(t)) return; const { status, body } = await apiPost("/api/services/9router/start"); assert.ok( status === 200, `Expected 200 from start, got ${status}: ${JSON.stringify(body).slice(0, 300)}` ); const b = body as Record; assert.ok( ["starting", "running"].includes(b.state as string), `Expected starting or running, got: ${b.state}` ); }); it("STEP 4: wait for 9router to become healthy (≤30s)", async (t) => { if (maybeSkip(t)) return; const finalStatus = await waitForState("/api/services/9router/status", "running", 30_000); const b = finalStatus as Record; assert.equal(b.state, "running"); assert.equal(b.health, "healthy"); assert.ok(typeof b.pid === "number", "pid must be a number when running"); }); it("STEP 5: GET /v1/models returns 9router models", async (t) => { if (maybeSkip(t)) return; const { status, body } = await apiGet("/api/services/9router/models"); assert.equal(status, 200); const b = body as Record; assert.ok(Array.isArray(b.data), "models response must have data array"); }); it("STEP 6: POST /v1/chat/completions with 9router model returns 200", async (t) => { if (maybeSkip(t)) return; const { status, body } = await apiPost("/v1/chat/completions", { model: "9router/auto", messages: [{ role: "user", content: "Say OK" }], max_tokens: 10, stream: false, }); assert.ok( status === 200, `Expected 200 from chat, got ${status}: ${JSON.stringify(body).slice(0, 300)}` ); }); it("STEP 7: stop 9router", async (t) => { if (maybeSkip(t)) return; const { status, body } = await apiPost("/api/services/9router/stop"); assert.equal(status, 200); const b = body as Record; assert.ok( ["stopping", "stopped"].includes(b.state as string), `Expected stopping or stopped, got: ${b.state}` ); }); it("STEP 8: status returns stopped after stop", async (t) => { if (maybeSkip(t)) return; const final = await waitForState("/api/services/9router/status", "stopped", 15_000); assert.equal((final as Record).state, "stopped"); }); }); // --------------------------------------------------------------------------- // cliproxy lifecycle // --------------------------------------------------------------------------- describe("cliproxy — full lifecycle (opt-in, RUN_SERVICES_INT=1)", () => { it("STEP 1: install cliproxy (latest)", async (t) => { if (maybeSkip(t)) return; const { status, body } = await apiPost("/api/services/cliproxy/install", { version: "latest", }); assert.ok( status === 200, `Expected 200 from install, got ${status}: ${JSON.stringify(body).slice(0, 300)}` ); const b = body as Record; assert.ok(b.ok === true, "install response must have ok:true"); assert.ok( typeof b.installedVersion === "string", "install response must have installedVersion" ); }); it("STEP 2: start cliproxy", async (t) => { if (maybeSkip(t)) return; const { status, body } = await apiPost("/api/services/cliproxy/start"); assert.ok( status === 200, `Expected 200 from start, got ${status}: ${JSON.stringify(body).slice(0, 300)}` ); }); it("STEP 3: wait for cliproxy to become healthy (≤30s)", async (t) => { if (maybeSkip(t)) return; const finalStatus = await waitForState("/api/services/cliproxy/status", "running", 30_000); const b = finalStatus as Record; assert.equal(b.state, "running"); assert.equal(b.health, "healthy"); }); it("STEP 4: stop cliproxy", async (t) => { if (maybeSkip(t)) return; const { status } = await apiPost("/api/services/cliproxy/stop"); assert.equal(status, 200); }); it("STEP 5: status returns stopped after stop", async (t) => { if (maybeSkip(t)) return; const final = await waitForState("/api/services/cliproxy/status", "stopped", 15_000); assert.equal((final as Record).state, "stopped"); }); }); // --------------------------------------------------------------------------- // bifrost lifecycle // --------------------------------------------------------------------------- describe("bifrost — full lifecycle (opt-in, RUN_SERVICES_INT=1)", () => { it("STEP 1: install bifrost (latest)", async (t) => { if (maybeSkip(t)) return; const { status, body } = await apiPost("/api/services/bifrost/install", { version: "latest", }); assert.ok( status === 200, `Expected 200 from install, got ${status}: ${JSON.stringify(body).slice(0, 300)}` ); const b = body as Record; assert.ok(b.ok === true, "install response must have ok:true"); assert.ok( typeof b.installedVersion === "string", "install response must have installedVersion" ); assert.ok(typeof b.durationMs === "number", "install response must have durationMs"); }); it("STEP 2: verify status is stopped after install", async (t) => { if (maybeSkip(t)) return; const { status, body } = await apiGet("/api/services/bifrost/status"); assert.equal(status, 200); const b = body as Record; assert.equal(b.state, "stopped", `Expected stopped state after install, got: ${b.state}`); assert.ok( typeof b.installedVersion === "string", "installedVersion should be set after install" ); }); it("STEP 3: start bifrost", async (t) => { if (maybeSkip(t)) return; const { status, body } = await apiPost("/api/services/bifrost/start"); assert.ok( status === 200, `Expected 200 from start, got ${status}: ${JSON.stringify(body).slice(0, 300)}` ); const b = body as Record; assert.ok( ["starting", "running"].includes(b.state as string), `Expected starting or running, got: ${b.state}` ); }); it("STEP 4: wait for bifrost to become healthy (≤60s — Go binary download on first start)", async (t) => { if (maybeSkip(t)) return; // First start lazily downloads the Go binary, so allow a longer window than // the 9router/cliproxy blocks (30s). Confirms open question §2b/§6 (Windows + // headless boot) against a real download. const finalStatus = await waitForState("/api/services/bifrost/status", "running", 60_000); const b = finalStatus as Record; assert.equal(b.state, "running"); assert.equal(b.health, "healthy"); assert.ok(typeof b.pid === "number", "pid must be a number when running"); assert.equal(b.port, 8080, "bifrost must report its default port 8080"); }); it("STEP 5: stop bifrost", async (t) => { if (maybeSkip(t)) return; const { status, body } = await apiPost("/api/services/bifrost/stop"); assert.equal(status, 200); const b = body as Record; assert.ok( ["stopping", "stopped"].includes(b.state as string), `Expected stopping or stopped, got: ${b.state}` ); }); it("STEP 6: status returns stopped after stop", async (t) => { if (maybeSkip(t)) return; const final = await waitForState("/api/services/bifrost/status", "stopped", 15_000); assert.equal((final as Record).state, "stopped"); }); }); // --------------------------------------------------------------------------- // Security smoke (requires running server) // --------------------------------------------------------------------------- describe("Route guard security smoke (opt-in, RUN_SERVICES_INT=1)", () => { it("POST /api/services/9router/start with X-Forwarded-For non-loopback → 403", async (t) => { if (maybeSkip(t)) return; // Note: this only works when the server enforces the loopback check via the // route guard. In local dev, the host header may override — this test is most // meaningful in CI with the real server bound to 127.0.0.1. const res = await fetch(`${BASE_URL}/api/services/9router/start`, { method: "POST", headers: { "Content-Type": "application/json", "X-Forwarded-For": "1.2.3.4", Host: "evil.tunnel.example.com", }, body: JSON.stringify({}), }); // 403 expected from route guard; 405 possible if server already blocked at method level. assert.ok( res.status === 403 || res.status === 405, `Expected 403 from non-loopback request, got ${res.status}` ); }); });