import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, it, expect, afterEach, vi } from "vitest"; // POST /api/provider-nodes registers a node only — API keys are added later // from the node page through POST /api/providers. The node row it creates MUST // carry the same providerSpecificData the connection created later will copy, // otherwise a key added after the fact routes differently from the old // one-call flow. const originalDataDir = process.env.DATA_DIR; let tempDir = null; function resetDataDir(prefix) { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); process.env.DATA_DIR = tempDir; // src/lib/db/driver.js caches the opened adapter on `global` to survive Next // hot-reload; without this every test would share the first temp DB. global._dbAdapter = { instance: null, initPromise: null, logged: false }; vi.resetModules(); vi.doMock("next/server", () => ({ NextResponse: { json(body, init = {}) { return new Response(JSON.stringify(body), { status: init.status || 200, headers: { "Content-Type": "application/json" }, }); }, }, })); } afterEach(() => { // Drop the cached adapter BEFORE deleting its file: driver.js keeps the // opened handle on `global`, so any later file in this worker would otherwise // write into a removed database. global._dbAdapter = { instance: null, initPromise: null, logged: false }; if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); tempDir = null; if (originalDataDir === undefined) delete process.env.DATA_DIR; else process.env.DATA_DIR = originalDataDir; }); function post(url, body) { return new Request(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); } async function loadRoutes() { const nodes = await import("@/app/api/provider-nodes/route.js"); const providers = await import("@/app/api/providers/route.js"); const models = await import("@/models/index.js"); return { nodes, providers, models }; } describe("provider-nodes POST creates a node without a key", () => { it("creates an openai-compatible node and stores no connection", async () => { resetDataDir("9router-node-create-"); const { nodes, models } = await loadRoutes(); const res = await nodes.POST(post("https://9router.local/api/provider-nodes", { type: "openai-compatible", apiType: "responses", name: "My Relay", prefix: "relay", baseUrl: "https://relay.example.com/v1", })); expect(res.status).toBe(201); const created = await res.json(); expect(created.node?.id).toContain("openai-compatible-"); expect(created.node.name).toBe("My Relay"); expect(created.node.baseUrl).toBe("https://relay.example.com/v1"); expect(created.node.prefix).toBe("relay"); expect(created.node.apiType).toBe("responses"); // Creating a node must never fabricate a connection. expect(created.connection).toBeUndefined(); expect(created.connectionError).toBeUndefined(); expect(await models.getProviderConnections()).toEqual([]); }); it("ignores a stray apiKey instead of opening a connection", async () => { resetDataDir("9router-node-create-stray-key-"); const { nodes, models } = await loadRoutes(); const res = await nodes.POST(post("https://9router.local/api/provider-nodes", { type: "openai-compatible", apiType: "chat", name: "Legacy Client", prefix: "lc", baseUrl: "https://lc.example.com/v1", apiKey: "sk-should-be-dropped", defaultModel: "gpt-5.1", testStatus: "active", })); expect(res.status).toBe(201); const body = await res.json(); expect(body.connection).toBeUndefined(); expect(JSON.stringify(body)).not.toContain("sk-should-be-dropped"); expect(await models.getProviderConnections()).toEqual([]); }); it("strips /messages for anthropic-compatible and omits apiType", async () => { resetDataDir("9router-node-create-anthropic-"); const { nodes } = await loadRoutes(); const body = await ( await nodes.POST(post("https://9router.local/api/provider-nodes", { type: "anthropic-compatible", name: "Claude Proxy", prefix: "cpx", baseUrl: "https://proxy.example.com/v1/messages", })) ).json(); expect(body.node.baseUrl).toBe("https://proxy.example.com/v1"); expect(body.node.providerSpecificData).toBeUndefined(); expect(body.node).toMatchObject({ type: "anthropic-compatible", prefix: "cpx", baseUrl: "https://proxy.example.com/v1", name: "Claude Proxy", }); }); it("creates a custom-embedding node", async () => { resetDataDir("9router-node-create-embedding-"); const { nodes, models } = await loadRoutes(); const res = await nodes.POST(post("https://9router.local/api/provider-nodes", { type: "custom-embedding", name: "Voyage", prefix: "voyage", baseUrl: "https://api.voyageai.com/v1", })); expect(res.status).toBe(201); const body = await res.json(); expect(body.node.id).toContain("custom-embedding-"); expect(await models.getProviderConnections()).toEqual([]); }); it("rejects a bad proxy before creating anything", async () => { resetDataDir("9router-node-proxy-guard-"); const { nodes, models } = await loadRoutes(); const res = await nodes.POST(post("https://9router.local/api/provider-nodes", { type: "openai-compatible", apiType: "chat", name: "Nope", prefix: "np", baseUrl: "https://np.example.com/v1", connectionProxyEnabled: true, })); expect(res.status).toBe(400); expect((await res.json()).error).toContain("proxy URL is required"); expect(await models.getProviderNodes()).toEqual([]); expect(await models.getProviderConnections()).toEqual([]); }); it("keys added afterwards land on the node with the node's own data", async () => { resetDataDir("9router-node-later-key-"); const { nodes, providers, models } = await loadRoutes(); const node = ( await ( await nodes.POST(post("https://9router.local/api/provider-nodes", { type: "openai-compatible", apiType: "responses", name: "Relay", prefix: "relay", baseUrl: "https://relay.example.com/v1", })) ).json() ).node; const added = await ( await providers.POST(post("https://9router.local/api/providers", { provider: node.id, name: "First Key", apiKey: "sk-later", })) ).json(); expect(added.connection).toBeTruthy(); expect(added.connection.provider).toBe(node.id); expect(added.connection.authType).toBe("apikey"); expect(added.connection.isActive).toBe(true); expect(added.connection.apiKey).toBeUndefined(); // Connection copies the node row, so the prefix/apiType survive. expect(added.connection.providerSpecificData.prefix).toBe("relay"); expect(added.connection.providerSpecificData.apiType).toBe("responses"); const stored = (await models.getProviderConnections()) .filter((c) => c.provider === node.id) .map((c) => c.apiKey); expect(stored).toEqual(["sk-later"]); }); });