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

194 lines
7.7 KiB
TypeScript

/**
* T-04 lifecycle endpoint tests.
*
* Tests HTTP handlers directly (bypassing Next.js routing) with a real
* ServiceSupervisor whose start/stop methods are patched via mock.fn().
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { mock } from "node:test";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-lifecycle-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.NODE_ENV = "test";
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
// Bootstrap DB
const core = await import("../../../src/lib/db/core.ts");
const db = core.getDbInstance();
// Seed service rows
db.prepare(
`INSERT OR IGNORE INTO version_manager (tool, status, port, auto_start, auto_update, provider_expose)
VALUES ('9router', 'stopped', 20130, 0, 1, 1)`
).run();
const { updateVersionManagerTool, getVersionManagerTool } =
await import("../../../src/lib/db/versionManager.ts");
// Set version so installer functions have something to read
await updateVersionManagerTool("9router", { installedVersion: "0.4.59", status: "stopped" });
const { ServiceSupervisor } = await import("../../../src/lib/services/ServiceSupervisor.ts");
const { registerSupervisor, getSupervisor } = await import("../../../src/lib/services/registry.ts");
/** Creates a minimal ServiceSupervisor with fake spawnArgs (no real process). */
function makeFakeSup(tool: string) {
return new ServiceSupervisor({
tool,
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,
});
}
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ─── status endpoint ────────────────────────────────────────────────────────
test("GET /status returns not_installed when no installer version", async () => {
await updateVersionManagerTool("9router", { installedVersion: null, status: "not_installed" });
const { GET } = await import("../../../src/app/api/services/9router/status/route.ts?t=status-1");
const resp = await GET();
assert.equal(resp.status, 200);
const body = await resp.json();
assert.ok(["not_installed", "unknown", "stopped"].includes(body.state as string));
// restore
await updateVersionManagerTool("9router", { installedVersion: "0.4.59", status: "stopped" });
});
test("GET /status returns enriched shape", async () => {
const { GET } = await import("../../../src/app/api/services/9router/status/route.ts?t=status-2");
const resp = await GET();
assert.equal(resp.status, 200);
const body = await resp.json();
assert.ok("state" in body, "should have state");
assert.ok("installedVersion" in body, "should have installedVersion");
assert.ok("updateAvailable" in body, "should have updateAvailable");
assert.ok("apiKeyMasked" in body, "should have apiKeyMasked");
});
// ─── start endpoint ─────────────────────────────────────────────────────────
test("POST /start returns 409 when not_installed", async () => {
await updateVersionManagerTool("9router", { status: "not_installed", installedVersion: null });
const { POST } = await import("../../../src/app/api/services/9router/start/route.ts?t=start-1");
const resp = await POST();
assert.equal(resp.status, 409);
const body = await resp.json();
assert.ok(body.error?.message?.includes("instalado"), "should explain not installed");
// restore
await updateVersionManagerTool("9router", { status: "stopped", installedVersion: "0.4.59" });
});
test("POST /start returns 200 when already running", async () => {
const sup = makeFakeSup("9router");
// Patch start() to return immediately with running state
const origStart = sup.start.bind(sup);
void origStart;
mock.method(sup, "start", async () => ({
tool: "9router",
state: "running" as const,
pid: 12345,
port: 20130,
health: "healthy" as const,
startedAt: new Date().toISOString(),
lastError: null,
}));
registerSupervisor(sup);
await updateVersionManagerTool("9router", { status: "stopped", installedVersion: "0.4.59" });
const { POST } = await import("../../../src/app/api/services/9router/start/route.ts?t=start-2");
const resp = await POST();
assert.equal(resp.status, 200);
const body = await resp.json();
assert.equal(body.state, "running");
mock.restoreAll();
});
// ─── stop endpoint ───────────────────────────────────────────────────────────
test("POST /stop returns stopped even if supervisor absent", async () => {
// Remove from registry by re-importing fresh
const { POST } = await import("../../../src/app/api/services/9router/stop/route.ts?t=stop-1");
const resp = await POST();
assert.equal(resp.status, 200);
const body = await resp.json();
assert.ok(["stopped", "running", "error"].includes(body.state as string));
});
// ─── auto-start endpoint ─────────────────────────────────────────────────────
test("POST /auto-start toggles DB field", async () => {
const { POST } = await import("../../../src/app/api/services/9router/auto-start/route.ts");
const reqEnable = new Request("http://localhost", {
method: "POST",
body: JSON.stringify({ enabled: true }),
});
const resp1 = await POST(reqEnable);
assert.equal(resp1.status, 204);
const row1 = await getVersionManagerTool("9router");
assert.ok(row1?.autoStart === true, "autoStart should be true after enable");
const reqDisable = new Request("http://localhost", {
method: "POST",
body: JSON.stringify({ enabled: false }),
});
const resp2 = await POST(reqDisable);
assert.equal(resp2.status, 204);
const row2 = await getVersionManagerTool("9router");
assert.ok(row2?.autoStart === false, "autoStart should be false after disable");
});
test("POST /auto-start returns 400 for invalid body", async () => {
const { POST } =
await import("../../../src/app/api/services/9router/auto-start/route.ts?t=as-bad");
const req = new Request("http://localhost", {
method: "POST",
body: JSON.stringify({ enabled: "yes" }), // should be boolean
});
const resp = await POST(req);
assert.equal(resp.status, 400);
});
// ─── rotate-key endpoint ─────────────────────────────────────────────────────
test("POST /rotate-key does not leak plain key in response", async () => {
const { POST } = await import("../../../src/app/api/services/9router/rotate-key/route.ts");
const resp = await POST();
assert.equal(resp.status, 200);
const body = await resp.json();
assert.ok(body.keyRotated === true, "keyRotated should be true");
assert.ok(!("key" in body), "response must not contain 'key' field");
assert.ok(!("apiKey" in body), "response must not contain 'apiKey' field");
assert.ok(!("plainKey" in body), "response must not contain 'plainKey' field");
// Ensure no string in the response looks like our key prefix
const bodyStr = JSON.stringify(body);
assert.ok(!bodyStr.includes("nr_"), "response must not contain plain key value");
});