Files
AgentHub/server/__tests__/ai-routers.test.js
T
nntrivi2001 00a2aedb09 feat(ai-router): test an endpoint before saving it, and re-point a live lane
Two gaps left by the AI Router feature: a profile could be saved without ever
being reachable, and a lane's router could only be chosen at creation.

**POST /api/ai-routers/test** answers the question saving cannot. It sends one
Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages`
with a 15s timeout — the wire shape a claude lane's eight env keys point at, so
what is tested is what will actually run. A router that answers is a 200 here
whatever it replies: `{ ok, status, message }` carries the router's own words,
and `status` is null when nothing answered at all. Only bad input is a 4xx.

The stored key is only ever sent to the endpoint it was stored against. A blank
`apiKey` falls back to the key of the profile named by `name`, but only while
the submitted `baseUrl` still equals the saved one — so testing a retyped
endpoint needs a retyped key (`EBADKEY`). Without that rule this route would be
a way to make the server POST a saved API key to any URL a browser names.

Network failures report `error.cause`, not `error.message`: node's fetch reports
every transport failure as the same useless "fetch failed" and hides the part
the user can act on (ECONNREFUSED, ENOTFOUND, a TLS error) one level down.

**The lane card carries an AI Router select.** The Add-lane modal was the only
place a router was chosen, and a lane outlives its creation. Switching goes
through `POST /api/lanes/:id/router/apply` — never a lane PATCH — because the
DB column and the lane's own config on disk may only move together. A failed
apply puts the select back rather than leaving the card claiming a router the
lane did not get. Hidden when `native` is the only profile: a select with one
option is a control that cannot do anything.

Adds `Lane.router_profile` to the client type (the payload already carried it).
2026-09-16 11:26:00 +07:00

274 lines
10 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file Tests for the AI Router profile store — the CRUD surface, the built-in
* native profile, and the guarantee that a stored API key never appears in a
* response the browser can read.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ai-routers-"));
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const routers = require("../lib/ai-routers");
const ROUTERS_DIR = path.join(process.env.CLAUDE_HOME, "agenthub-routers");
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
beforeEach(() => fs.rmSync(ROUTERS_DIR, { recursive: true, force: true }));
/** The smallest profile the API accepts. */
function nineRouter(over = {}) {
return {
label: "9Router",
baseUrl: "http://localhost:20128/v1",
apiKey: "sk-secret-value",
model: "Highest",
...over,
};
}
describe("ai-routers — the built-in native profile", () => {
it("is listed even before anything has been saved", () => {
const { items } = routers.listRouters();
assert.equal(items.length, 1);
assert.equal(items[0].name, "native");
assert.equal(items[0].builtin, true);
});
it("leaves the two connection variables empty rather than absent", () => {
// The reference native settings.json sets both to "" — an absent key would
// let a user-level router base URL leak into a lane meant to run native.
const native = routers.getRouter("native");
assert.equal(native.baseUrl, "");
assert.equal(native.apiKey, "");
});
it("refuses to be edited or removed", () => {
assert.throws(() => routers.updateRouter("native", { label: "mine" }), { code: "EBUILTIN" });
assert.throws(() => routers.removeRouter("native"), { code: "EBUILTIN" });
});
});
describe("ai-routers — create", () => {
it("saves a profile and derives its name from the label", () => {
const created = routers.createRouter(nineRouter());
assert.equal(created.name, "9Router".toLowerCase());
assert.equal(created.label, "9Router");
assert.ok(fs.existsSync(path.join(ROUTERS_DIR, `${created.name}.json`)));
});
it("stores the file 0600 inside a 0700 directory", () => {
const created = routers.createRouter(nineRouter());
const file = fs.statSync(path.join(ROUTERS_DIR, `${created.name}.json`));
const dir = fs.statSync(ROUTERS_DIR);
assert.equal(file.mode & 0o777, 0o600);
assert.equal(dir.mode & 0o777, 0o700);
});
it("fills every model slot from `model` when no per-tier value is given", () => {
// The 9Router reference sets all six slots to one symbolic value; typing it
// once must be enough.
routers.createRouter(nineRouter());
const saved = routers.getRouter("9router");
assert.equal(saved.models.opus, "Highest");
assert.equal(saved.models.sonnet, "Highest");
assert.equal(saved.models.haiku, "Highest");
assert.equal(saved.models.smallFast, "Highest");
assert.equal(saved.models.subagent, "Highest");
});
it("keeps a per-tier value that was given explicitly", () => {
routers.createRouter(nineRouter({ models: { opus: "PremiumCombo" } }));
const saved = routers.getRouter("9router");
assert.equal(saved.models.opus, "PremiumCombo");
assert.equal(saved.models.sonnet, "Highest");
});
it("gives a second profile with the same label a free name", () => {
const a = routers.createRouter(nineRouter());
const b = routers.createRouter(nineRouter());
assert.notEqual(a.name, b.name);
});
it("rejects a label that is empty or carries control characters", () => {
assert.throws(() => routers.createRouter(nineRouter({ label: " " })), { code: "EBADLABEL" });
assert.throws(() => routers.createRouter(nineRouter({ label: "ab" })), {
code: "EBADLABEL",
});
});
it("rejects a base URL that is not http(s)", () => {
// A `file:` or `javascript:` base URL is never a router; it is either a
// mistake or an attempt to point the CLI somewhere it should not go.
assert.throws(() => routers.createRouter(nineRouter({ baseUrl: "file:///etc/passwd" })), {
code: "EBADURL",
});
assert.throws(() => routers.createRouter(nineRouter({ baseUrl: "not a url" })), {
code: "EBADURL",
});
});
it("refuses to shadow the built-in native name", () => {
assert.throws(() => routers.createRouter(nineRouter({ label: "native" })), {
code: "EBUILTIN",
});
});
});
describe("ai-routers — the key never reaches the browser", () => {
it("is absent from the list, which reports only that one is set", () => {
routers.createRouter(nineRouter());
const { items } = routers.listRouters();
const saved = items.find((i) => i.name === "9router");
assert.equal(saved.hasKey, true);
assert.equal(saved.apiKey, undefined);
assert.ok(!JSON.stringify(items).includes("sk-secret-value"));
});
it("is still absent when the profile has no key at all", () => {
routers.createRouter(nineRouter({ apiKey: "" }));
const { items } = routers.listRouters();
assert.equal(items.find((i) => i.name === "9router").hasKey, false);
});
});
describe("ai-routers — update", () => {
it("changes the label without changing the name a lane already references", () => {
// A lane stores the name. Renaming the label must not orphan that lane.
const created = routers.createRouter(nineRouter());
const updated = routers.updateRouter(created.name, { label: "9Router (nhà)" });
assert.equal(updated.name, created.name);
assert.equal(updated.label, "9Router (nhà)");
});
it("keeps the stored key when the update omits it", () => {
// The edit form cannot show the key, so it cannot send it back either.
const created = routers.createRouter(nineRouter());
routers.updateRouter(created.name, { label: "renamed" });
assert.equal(routers.getRouter(created.name).apiKey, "sk-secret-value");
});
it("replaces the key when a new one is given", () => {
const created = routers.createRouter(nineRouter());
routers.updateRouter(created.name, { apiKey: "sk-rotated" });
assert.equal(routers.getRouter(created.name).apiKey, "sk-rotated");
});
it("reports a name that does not exist", () => {
assert.throws(() => routers.updateRouter("ghost", { label: "x" }), { code: "ENOTFOUND" });
});
});
describe("ai-routers — remove", () => {
it("deletes the file", () => {
const created = routers.createRouter(nineRouter());
routers.removeRouter(created.name);
assert.equal(fs.existsSync(path.join(ROUTERS_DIR, `${created.name}.json`)), false);
assert.equal(routers.listRouters().items.length, 1); // native only
});
it("reports a name that does not exist", () => {
assert.throws(() => routers.removeRouter("ghost"), { code: "ENOTFOUND" });
});
});
describe("ai-routers — a corrupt file is skipped, never fatal", () => {
it("lists the healthy profiles around it", () => {
routers.createRouter(nineRouter());
fs.writeFileSync(path.join(ROUTERS_DIR, "broken.json"), "{not json");
const { items } = routers.listRouters();
assert.ok(items.some((i) => i.name === "9router"));
assert.ok(!items.some((i) => i.name === "broken"));
});
});
describe("ai-routers — testRouter", () => {
const realFetch = global.fetch;
after(() => {
global.fetch = realFetch;
});
/** Capture the one request testRouter makes, and answer it however asked. */
function stubFetch(answer) {
const calls = [];
global.fetch = async (url, init) => {
calls.push({ url, init });
if (answer instanceof Error) throw answer;
return answer;
};
return calls;
}
const ok = () => ({ ok: true, status: 200, statusText: "OK", text: async () => "{}" });
it("posts one capped message to the endpoint's /v1/messages", async () => {
const calls = stubFetch(ok());
const result = await routers.testRouter(nineRouter());
assert.equal(result.ok, true);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, "http://localhost:20128/v1/v1/messages");
const body = JSON.parse(calls[0].init.body);
assert.equal(body.max_tokens, 1);
assert.equal(body.model, "Highest");
assert.equal(calls[0].init.headers["x-api-key"], "sk-secret-value");
});
it("reports a router that answered with an error rather than throwing", async () => {
stubFetch({ ok: false, status: 401, statusText: "Unauthorized", text: async () => "bad key" });
const result = await routers.testRouter(nineRouter());
assert.deepEqual(result, { ok: false, status: 401, message: "bad key" });
});
it("reports an endpoint that never answered", async () => {
// The shape node's own fetch throws: a useless outer message wrapping the
// one thing the user can act on.
stubFetch(
Object.assign(new Error("fetch failed"), {
cause: new Error("connect ECONNREFUSED 127.0.0.1:20199"),
})
);
const result = await routers.testRouter(nineRouter());
assert.equal(result.ok, false);
assert.equal(result.status, null);
assert.match(result.message, /ECONNREFUSED/);
});
it("falls back to the stored key for the endpoint it was stored against", async () => {
const created = routers.createRouter(nineRouter());
const calls = stubFetch(ok());
await routers.testRouter({
name: created.name,
baseUrl: "http://localhost:20128/v1",
model: "Highest",
});
assert.equal(calls[0].init.headers["x-api-key"], "sk-secret-value");
});
it("never sends the stored key to a different endpoint", async () => {
const created = routers.createRouter(nineRouter());
const calls = stubFetch(ok());
await assert.rejects(
routers.testRouter({
name: created.name,
baseUrl: "https://attacker.example/v1",
model: "Highest",
}),
{ code: "EBADKEY" }
);
assert.equal(calls.length, 0);
});
it("refuses a base URL that is not http(s)", async () => {
await assert.rejects(routers.testRouter(nineRouter({ baseUrl: "file:///etc/passwd" })), {
code: "EBADURL",
});
});
});