569b68ec98
Settings → AI Proxy gains an admin-only Update 9router button beside Config 9router. One in-memory job (server/lib/nine-router-update.js) stops 9router (its user unit, else this account's own 9router processes), runs npm install -g --prefix <its prefix> 9router@latest (sudo -n only when sudoers already allows it, probed before anything stops), then relaunches it hidden only if it was running. A failed update still relaunches the old version. Nothing in the request names a command, path or package.
263 lines
9.6 KiB
JavaScript
263 lines
9.6 KiB
JavaScript
/**
|
|
* @file nine-router-update.test.js
|
|
* @description The **Update 9router** job (`server/lib/nine-router-update.js`)
|
|
* and its routes, against a FAKE 9router: a throwaway npm prefix whose `cli.js`
|
|
* listens on a free port and keeps a renamed child, and a fake `npm` that bumps
|
|
* its version. PATH is cut down so the machine's real 9router can never be
|
|
* found, let alone stopped — every test first checks the located install is
|
|
* the fake one. No systemd: the unit path is a seam turned off here.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, before, after, beforeEach } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const net = require("node:net");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { spawn } = require("node:child_process");
|
|
|
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "nine-router-update-"));
|
|
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
|
const PREFIX = path.join(TMP, "prefix");
|
|
const PKG_DIR = path.join(PREFIX, "lib", "node_modules", "9router");
|
|
const BIN_DIR = path.join(PREFIX, "bin");
|
|
const NPM_DIR = path.join(TMP, "npmbin");
|
|
const ORIGINAL_PATH = process.env.PATH;
|
|
|
|
const update = require("../lib/nine-router-update");
|
|
const express = require("express");
|
|
const router = require("../routes/ai-proxies");
|
|
|
|
const CLI = `#!/usr/bin/env node
|
|
const net = require("net");
|
|
const { spawn } = require("child_process");
|
|
const i = process.argv.indexOf("-p");
|
|
const port = Number(process.argv[i + 1]);
|
|
// A child that renames itself, like 9router's next-server.
|
|
const child = spawn(process.execPath, ["-e", "process.title='fake-next-server'; setInterval(()=>{},1e6)"], { stdio: "ignore" });
|
|
const srv = net.createServer((s) => s.end()).listen(port, "127.0.0.1");
|
|
process.on("SIGTERM", () => { try { child.kill("SIGKILL"); } catch {} srv.close(); process.exit(0); });
|
|
setInterval(() => {}, 1e6);
|
|
`;
|
|
|
|
const NPM = `#!/bin/sh
|
|
# fake npm: --version, or install -g --prefix P ... 9router@latest
|
|
if [ "$1" = "--version" ]; then echo 10.0.0; exit 0; fi
|
|
while [ $# -gt 0 ]; do
|
|
if [ "$1" = "--prefix" ]; then P="$2"; shift; fi
|
|
shift
|
|
done
|
|
[ -f "${TMP}/npm-fail" ] && { echo "boom" >&2; exit 1; }
|
|
echo '{"name":"9router","version":"9.9.9"}' > "$P/lib/node_modules/9router/package.json"
|
|
echo installed
|
|
`;
|
|
|
|
function freePort() {
|
|
return new Promise((resolve) => {
|
|
const s = net.createServer().listen(0, "127.0.0.1", () => {
|
|
const { port } = s.address();
|
|
s.close(() => resolve(port));
|
|
});
|
|
});
|
|
}
|
|
|
|
const portOpen = (port) =>
|
|
new Promise((resolve) => {
|
|
const s = net.connect({ host: "127.0.0.1", port });
|
|
s.once("connect", () => (s.destroy(), resolve(true)));
|
|
s.once("error", () => resolve(false));
|
|
});
|
|
|
|
async function until(fn, ms = 20000) {
|
|
const end = Date.now() + ms;
|
|
while (Date.now() < end) {
|
|
if (await fn()) return true;
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** pids whose argv names the fake install — what a test must clean up. */
|
|
function fakePids() {
|
|
const out = [];
|
|
for (const d of fs.readdirSync("/proc")) {
|
|
if (!/^\d+$/.test(d)) continue;
|
|
try {
|
|
const argv = fs.readFileSync(`/proc/${d}/cmdline`, "utf8");
|
|
if (argv.includes(TMP) || argv.includes("fake-next-server")) out.push(Number(d));
|
|
} catch {
|
|
/* gone */
|
|
}
|
|
}
|
|
return out.filter((p) => p !== process.pid);
|
|
}
|
|
|
|
function killFakes() {
|
|
for (const pid of fakePids()) {
|
|
try {
|
|
process.kill(pid, "SIGKILL");
|
|
} catch {
|
|
/* gone */
|
|
}
|
|
}
|
|
}
|
|
|
|
function startFake(port) {
|
|
const child = spawn(process.execPath, [path.join(PKG_DIR, "cli.js"), "--tray", "-p", String(port)], {
|
|
stdio: "ignore",
|
|
detached: true,
|
|
});
|
|
child.unref();
|
|
return child;
|
|
}
|
|
|
|
async function finished() {
|
|
await until(() => update.current()?.status !== "running", 60000);
|
|
return update.current();
|
|
}
|
|
|
|
let server;
|
|
let BASE;
|
|
|
|
before(async () => {
|
|
fs.mkdirSync(PKG_DIR, { recursive: true });
|
|
fs.mkdirSync(BIN_DIR, { recursive: true });
|
|
fs.mkdirSync(NPM_DIR, { recursive: true });
|
|
fs.writeFileSync(path.join(PKG_DIR, "cli.js"), CLI, { mode: 0o755 });
|
|
fs.symlinkSync("../lib/node_modules/9router/cli.js", path.join(BIN_DIR, "9router"));
|
|
fs.writeFileSync(path.join(NPM_DIR, "npm"), NPM, { mode: 0o755 });
|
|
// Only the fake bins, node's own dir and the basics — never /usr/local/bin.
|
|
process.env.PATH = [BIN_DIR, NPM_DIR, path.dirname(process.execPath), "/bin", "/usr/bin"].join(":");
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/api/ai-proxies", router);
|
|
await new Promise((resolve) => {
|
|
server = app.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
|
});
|
|
|
|
beforeEach(() => {
|
|
killFakes();
|
|
update.__reset();
|
|
fs.writeFileSync(path.join(PKG_DIR, "package.json"), JSON.stringify({ name: "9router", version: "1.0.0" }));
|
|
fs.rmSync(path.join(TMP, "npm-fail"), { force: true });
|
|
update.__setSeams({
|
|
useSystemd: false,
|
|
fetchLatest: async () => "2.0.0",
|
|
timing: { termGraceMs: 2000, portFreeMs: 5000, portUpMs: 15000 },
|
|
});
|
|
const loc = update.locate();
|
|
assert.ok(loc && loc.pkgDir.startsWith(TMP), `refusing to run against ${loc?.pkgDir}`);
|
|
});
|
|
|
|
after(() => {
|
|
killFakes();
|
|
process.env.PATH = ORIGINAL_PATH;
|
|
server?.close();
|
|
fs.rmSync(TMP, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("nine-router-update", () => {
|
|
it("reports the install, the latest version and that nothing runs", async () => {
|
|
const s = await update.status();
|
|
assert.equal(s.installed, true);
|
|
assert.equal(s.version, "1.0.0");
|
|
assert.equal(s.latest, "2.0.0");
|
|
assert.equal(s.updateAvailable, true);
|
|
assert.equal(s.running, false);
|
|
assert.equal(s.blocker, null);
|
|
});
|
|
|
|
it("refuses when already on the latest version", async () => {
|
|
update.__setSeams({ fetchLatest: async () => "1.0.0" });
|
|
const s = await update.status();
|
|
assert.equal(s.blocker.code, "EUPTODATE");
|
|
await assert.rejects(update.start(), { code: "EUPTODATE" });
|
|
});
|
|
|
|
it("refuses with a manual command when the prefix is not writable and sudo asks", async () => {
|
|
fs.chmodSync(path.join(PREFIX, "lib", "node_modules"), 0o555);
|
|
update.__setSeams({
|
|
exec: async (file, args) =>
|
|
file === "sudo" ? { code: 1, stdout: "", stderr: "a password is required" } : { code: 0, stdout: "", stderr: "" },
|
|
});
|
|
try {
|
|
if (process.getuid?.() === 0) return; // root writes anyway
|
|
await assert.rejects(update.start(), (e) => e.code === "ENOPERM" && /sudo npm install -g 9router@latest/.test(e.command));
|
|
assert.equal(update.current(), null, "no job, so 9router was never stopped");
|
|
} finally {
|
|
fs.chmodSync(path.join(PREFIX, "lib", "node_modules"), 0o755);
|
|
}
|
|
});
|
|
|
|
it("stops a running 9router (child included), updates it, and starts it again hidden", async () => {
|
|
const port = await freePort();
|
|
startFake(port);
|
|
assert.ok(await until(() => portOpen(port)), "fake 9router came up");
|
|
assert.ok(await until(() => fakePids().length >= 2), "with its child");
|
|
const before = fakePids();
|
|
|
|
const job = await update.start();
|
|
assert.equal(job.status, "running");
|
|
assert.equal(job.managedBy, "process");
|
|
const done = await finished();
|
|
assert.equal(done.status, "done", done.log + JSON.stringify(done.steps));
|
|
assert.deepEqual(done.steps.map((s) => s.status), ["done", "done", "done"]);
|
|
assert.equal(done.from, "1.0.0");
|
|
assert.equal(done.to, "9.9.9");
|
|
for (const pid of before) assert.ok(!fakePids().includes(pid), `old pid ${pid} is gone`);
|
|
assert.ok(await portOpen(port), "relaunched on the same port");
|
|
// Relaunched hidden: tray mode, no browser, no self-update.
|
|
const argv = fakePids()
|
|
.map((p) => fs.readFileSync(`/proc/${p}/cmdline`, "utf8").split("\0"))
|
|
.find((a) => a.includes(path.join(PKG_DIR, "cli.js")));
|
|
for (const flag of ["--tray", "--no-browser", "--skip-update"]) assert.ok(argv.includes(flag), flag);
|
|
});
|
|
|
|
it("leaves 9router off when it was not running", async () => {
|
|
await update.start();
|
|
const done = await finished();
|
|
assert.equal(done.status, "done");
|
|
assert.deepEqual(done.steps.map((s) => s.status), ["skipped", "done", "skipped"]);
|
|
assert.equal(fakePids().length, 0);
|
|
});
|
|
|
|
it("still relaunches the old version when npm fails", async () => {
|
|
fs.writeFileSync(path.join(TMP, "npm-fail"), "");
|
|
const port = await freePort();
|
|
startFake(port);
|
|
assert.ok(await until(() => portOpen(port)));
|
|
await update.start();
|
|
const done = await finished();
|
|
assert.equal(done.status, "failed");
|
|
assert.deepEqual(done.steps.map((s) => s.status), ["done", "failed", "done"]);
|
|
assert.match(done.log, /boom/);
|
|
assert.ok(await portOpen(port));
|
|
});
|
|
|
|
it("refuses a second start while one runs", async () => {
|
|
const port = await freePort();
|
|
startFake(port);
|
|
assert.ok(await until(() => portOpen(port)));
|
|
await update.start();
|
|
await assert.rejects(update.start(), { code: "EBUSY" });
|
|
await finished();
|
|
});
|
|
});
|
|
|
|
describe("routes /api/ai-proxies/9router/update", () => {
|
|
it("GET reports status, POST starts and answers 202, a refusal is a 409", async () => {
|
|
const s = await (await fetch(`${BASE}/api/ai-proxies/9router/update`)).json();
|
|
assert.equal(s.version, "1.0.0");
|
|
const r = await fetch(`${BASE}/api/ai-proxies/9router/update`, { method: "POST" });
|
|
assert.equal(r.status, 202);
|
|
await finished();
|
|
update.__setSeams({ fetchLatest: async () => "9.9.9" });
|
|
const again = await fetch(`${BASE}/api/ai-proxies/9router/update`, { method: "POST" });
|
|
assert.equal(again.status, 409);
|
|
assert.equal((await again.json()).error.code, "EUPTODATE");
|
|
});
|
|
});
|